API: Add .well-known/oauth-authorization-server route handler #808 #3943

This commit also adds an /api/v1/oauth/logout endpoint that allows
clients to delete their sessions (access tokens) as needed.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-01-08 14:53:39 +01:00
parent f8e0615cc8
commit 0e4d81853c
33 changed files with 555 additions and 183 deletions

View File

@@ -77,3 +77,20 @@ func (acl ACL) AllowAll(resource Resource, role Role, perms Permissions) bool {
return true
}
// Resources returns the resources specified in the ACL.
func (acl ACL) Resources() (result []string) {
if len(acl) == 0 {
return []string{}
}
result = make([]string, 0, len(acl))
for resource := range acl {
if resource != ResourceDefault && resource.String() != "" {
result = append(result, resource.String())
}
}
return result
}

View File

@@ -120,3 +120,10 @@ func TestACL_DenyAll(t *testing.T) {
assert.False(t, Resources.DenyAll(ResourceFiles, RoleAdmin, Permissions{FullAccess, AccessShared, ActionView}))
})
}
func TestACL_Resources(t *testing.T) {
t.Run("Resources", func(t *testing.T) {
result := Resources.Resources()
assert.Len(t, result, 21)
})
}

View File

@@ -250,7 +250,8 @@ func DeleteAlbum(router *gin.RouterGroup) {
// LikeAlbum sets the favorite flag for an album.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Album UID
//
// POST /api/v1/albums/:uid/like
@@ -297,7 +298,8 @@ func LikeAlbum(router *gin.RouterGroup) {
// DislikeAlbum removes the favorite flag from an album.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Album UID
//
// DELETE /api/v1/albums/:uid/like

View File

@@ -16,6 +16,7 @@ const (
StatusUpdated Event = "updated"
StatusDeleted Event = "deleted"
StatusSuccess Event = "success"
StatusFailed Event = "failed"
)
// String returns the event type as string.

View File

@@ -8,13 +8,13 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/header"
"github.com/sirupsen/logrus"
)
type CloseableResponseRecorder struct {
@@ -31,15 +31,22 @@ func (r *CloseableResponseRecorder) closeClient() {
}
func TestMain(m *testing.M) {
// Init test logger.
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
// Init test config.
c := config.TestConfig()
get.SetConfig(c)
// Increase login rate limit for testing.
limiter.Login = limiter.NewLimit(1, 10000)
// Run unit tests.
code := m.Run()
// Close database connection.
_ = c.CloseDb()
os.Exit(code)

View File

@@ -23,7 +23,8 @@ const (
// AlbumCover returns an album cover image.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string album uid
// - token: string security token (see config)
// - size: string thumb type, see photoprism.ThumbnailTypes
@@ -135,7 +136,8 @@ func AlbumCover(router *gin.RouterGroup) {
// LabelCover returns a label cover image.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string label uid
// - token: string security token (see config)
// - size: string thumb type, see photoprism.ThumbnailTypes

View File

@@ -35,7 +35,8 @@ func DownloadName(c *gin.Context) customize.DownloadName {
// GetDownload returns the raw file data.
//
// Request Parameters:
// The request parameters are:
//
// - hash: string The file hash as returned by the files/photos endpoint
//
// GET /api/v1/dl/:hash

View File

@@ -7,7 +7,7 @@ import (
)
func TestSendFeedback(t *testing.T) {
t.Run("not available in public mode", func(t *testing.T) {
t.Run("PublicMode", func(t *testing.T) {
app, router, _ := NewApiTest()
SendFeedback(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/feedback", `{"Subject": "Send feedback from unit test", "Message": "Test message"}`)

View File

@@ -17,7 +17,8 @@ import (
// DeleteFile removes a file from storage.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//

View File

@@ -16,7 +16,8 @@ import (
// ChangeFileOrientation changes the orientation of a file.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//

View File

@@ -12,7 +12,8 @@ import (
// GetFile returns file details as JSON.
//
// Request Parameters:
// The request parameters are:
//
// - hash (string) SHA-1 hash of the file
//
// GET /api/v1/files/:hash

View File

@@ -21,7 +21,8 @@ const (
// FolderCover returns a folder cover image.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string folder uid
// - token: string url security token, see config
// - size: string thumb type, see thumb.Sizes

View File

@@ -54,7 +54,8 @@ func UpdateLabel(router *gin.RouterGroup) {
// LikeLabel flags a label as favorite.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Label UID
//
// POST /api/v1/labels/:uid/like
@@ -93,7 +94,8 @@ func LikeLabel(router *gin.RouterGroup) {
// DislikeLabel removes the favorite flag from a label.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Label UID
//
// DELETE /api/v1/labels/:uid/like

View File

@@ -174,7 +174,8 @@ func CreateMarker(router *gin.RouterGroup) {
// UpdateMarker updates an existing file area marker to assign faces or other subjects.
//
// Request Parameters:
// The request parameters are:
//
// - marker_uid: string Marker UID as returned by the API
//
// PUT /api/v1/markers/:marker_uid
@@ -265,7 +266,8 @@ func UpdateMarker(router *gin.RouterGroup) {
// ClearMarkerSubject removes an existing marker subject association.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
// - id: int Marker ID as returned by the API

View File

@@ -18,7 +18,8 @@ import (
// AddPhotoLabel adds a label to a photo.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
//
// POST /api/v1/photos/:uid/label
@@ -93,7 +94,8 @@ func AddPhotoLabel(router *gin.RouterGroup) {
// RemovePhotoLabel removes a label from a photo.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
// - id: int LabelId as returned by the API
//
@@ -158,7 +160,8 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
// UpdatePhotoLabel changes a photo labels.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
// - id: int LabelId as returned by the API
//

View File

@@ -19,7 +19,8 @@ import (
// PhotoUnstack removes a file from an existing photo stack.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//

View File

@@ -38,7 +38,8 @@ func SavePhotoAsYaml(p entity.Photo) {
// GetPhoto returns photo details as JSON.
//
// Request Parameters:
// The request parameters are:
//
// - uid (string) PhotoUID as returned by the API
//
// GET /api/v1/photos/:uid
@@ -124,7 +125,9 @@ func UpdatePhoto(router *gin.RouterGroup) {
// GetPhotoDownload returns the primary file matching that belongs to the photo.
//
// Route :GET /api/v1/photos/:uid/dl
// Request Parameters:
//
// The request parameters are:
//
// - uid (string) PhotoUID as returned by the API
func GetPhotoDownload(router *gin.RouterGroup) {
router.GET("/photos/:uid/dl", func(c *gin.Context) {
@@ -158,7 +161,8 @@ func GetPhotoDownload(router *gin.RouterGroup) {
// GetPhotoYaml returns photo details as YAML.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
//
// GET /api/v1/photos/:uid/yaml
@@ -194,7 +198,8 @@ func GetPhotoYaml(router *gin.RouterGroup) {
// ApprovePhoto marks a photo in review as approved.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
//
// POST /api/v1/photos/:uid/approve
@@ -230,7 +235,8 @@ func ApprovePhoto(router *gin.RouterGroup) {
// PhotoPrimary sets the primary file for a photo.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string PhotoUID as returned by the API
// - file_uid: string File UID as returned by the API
//

View File

@@ -9,63 +9,71 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// DeleteSession deletes an existing client session (logout).
//
// DELETE /api/v1/session
// DELETE /api/v1/session/:id
func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
deleteSessionHandler := func(c *gin.Context) {
// Abort if running in public mode.
if get.Config().Public() {
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(session.PublicID))
return
}
id := clean.ID(c.Param("id"))
// Abort if authentication token is missing or empty.
if id == "" {
AbortBadRequest(c)
// Get client IP address for logs and rate limiting checks.
clientIP := ClientIP(c)
// Find session based on auth token.
sess, err := entity.FindSession(rnd.SessionID(AuthToken(c)))
if err != nil || sess == nil {
Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized)
return
} else if get.Config().Public() {
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(id))
} else if sess.Abort(c) {
return
}
// Only admins may delete other sessions by reference id.
// Only admins may delete other sessions by ref id.
if rnd.IsRefID(id) {
if s := Session(AuthToken(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if s.Abort(c) {
return
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
s.Abort(c)
return
} else if ref := entity.FindSessionByRefID(id); ref == nil {
AbortNotFound(c)
return
} else {
id = ref.ID
}
} else {
if s := Session(AuthToken(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if s.Abort(c) {
return
} else if s.ID != id {
entity.SessionStatusForbidden().Abort(c)
if !acl.Resources.AllowAll(acl.ResourceUsers, sess.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
event.AuditErr([]string{clientIP, "session %s", "delete session %s as %s", "denied"}, sess.RefID, id, sess.User().AclRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
event.AuditInfo([]string{clientIP, "session %s", "delete session %s as %s", "granted"}, sess.RefID, id, sess.User().AclRole())
if sess = entity.FindSessionByRefID(id); sess == nil {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
return
}
} else if id != "" && sess.ID != id {
event.AuditWarn([]string{clientIP, "session %s", "delete session as %s", "ids do not match"}, sess.RefID, sess.User().AclRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
// Delete session cache and database record.
if err := get.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
if err = sess.Delete(); err != nil {
event.AuditErr([]string{clientIP, "session %s", "delete session as %s", "%s"}, sess.RefID, sess.User().AclRole(), err)
} else {
event.AuditDebug([]string{ClientIP(c), "session deleted"})
event.AuditDebug([]string{clientIP, "session %s", "deleted"}, sess.RefID)
}
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(id))
})
c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID))
}
router.DELETE("/session", deleteSessionHandler)
router.DELETE("/session/:id", deleteSessionHandler)
}

View File

@@ -14,7 +14,7 @@ import (
//
// GET /api/v1/session/:id
func GetSession(router *gin.RouterGroup) {
router.GET("/session/:id", func(c *gin.Context) {
getSessionHandler := func(c *gin.Context) {
id := clean.ID(c.Param("id"))
// Check authentication token.
@@ -60,5 +60,7 @@ func GetSession(router *gin.RouterGroup) {
// Return JSON response.
c.JSON(http.StatusOK, response)
})
}
router.GET("/session/:id", getSessionHandler)
}

View File

@@ -6,27 +6,37 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// CreateOauthToken creates a new access token for clients that
// CreateOAuthToken creates a new access token for clients that
// authenticate with valid OAuth2 client credentials.
//
// POST /api/v1/oauth/token
func CreateOauthToken(router *gin.RouterGroup) {
func CreateOAuthToken(router *gin.RouterGroup) {
router.POST("/oauth/token", func(c *gin.Context) {
// Get client IP address for logs and rate limiting checks.
clientIP := ClientIP(c)
// Abort if running in public mode.
if get.Config().Public() {
event.AuditErr([]string{clientIP, "create client session in public mode", "denied"})
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
// client_id, client_secret
var err error
var f form.ClientCredentials
// Get client IP address for logs and rate limiting checks.
clientIP := ClientIP(c)
// Allow authentication with basic auth and form values.
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
f.ClientID = clientId
@@ -55,20 +65,20 @@ func CreateOauthToken(router *gin.RouterGroup) {
// Abort if the client ID or secret are invalid.
if client == nil {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client id"}, f.ClientID)
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_id"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
limiter.Login.Reserve(clientIP)
return
} else if !client.AuthEnabled {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "running in public mode"}, f.ClientID)
event.AuditWarn([]string{clientIP, "client %s", "create session", "disabled"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if client.AuthMethod != authn.MethodOAuth2.String() {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "%s authentication not supported"}, f.ClientID, client.AuthMethod)
} else if method := client.Method(); !method.IsDefault() && method != authn.MethodOAuth2 {
event.AuditWarn([]string{clientIP, "client %s", "create session", "method %s not supported"}, f.ClientID, clean.LogQuote(method.String()))
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if client.WrongSecret(f.ClientSecret) {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client secret"}, f.ClientID)
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_secret"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
limiter.Login.Reserve(clientIP)
return
@@ -81,15 +91,15 @@ func CreateOauthToken(router *gin.RouterGroup) {
// Try to log in and save session if successful.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIP, "client %s", "create access token", "%s"}, f.ClientID, err)
event.AuditErr([]string{clientIP, "client %s", "create session", "%s"}, f.ClientID, err)
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if sess == nil {
event.AuditErr([]string{clientIP, "client %s", "create access token", "failed unexpectedly"}, f.ClientID)
event.AuditErr([]string{clientIP, "client %s", "create session", StatusFailed.String()}, f.ClientID)
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
return
} else {
event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID)
event.AuditInfo([]string{clientIP, "client %s", "session %s", "created"}, f.ClientID, sess.RefID)
}
// Response includes access token, token type, and token lifetime.
@@ -103,3 +113,59 @@ func CreateOauthToken(router *gin.RouterGroup) {
c.JSON(http.StatusOK, data)
})
}
// DeleteOAuthToken creates a new access token for clients that
// authenticate with valid OAuth2 client credentials.
//
// POST /api/v1/oauth/logout
func DeleteOAuthToken(router *gin.RouterGroup) {
router.POST("/oauth/logout", func(c *gin.Context) {
// Get client IP address for logs and rate limiting checks.
clientIP := ClientIP(c)
// Abort if running in public mode.
if get.Config().Public() {
event.AuditErr([]string{clientIP, "delete client session in public mode", "denied"})
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
// Find session based on auth token.
sess, err := entity.FindSession(rnd.SessionID(AuthToken(c)))
if err != nil {
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err.Error())
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return
} else if sess == nil {
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return
} else if sess.Abort(c) {
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
return
} else if !sess.IsClient() {
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden))
return
} else {
event.AuditInfo([]string{clientIP, "client %s", "session %s", "delete session as %s", "granted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
}
// Delete session cache and database record.
if err = sess.Delete(); err != nil {
// Log error.
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err)
// Return JSON error.
c.AbortWithStatusJSON(http.StatusNotFound, i18n.NewResponse(http.StatusNotFound, i18n.ErrNotFound))
return
}
// Log event.
event.AuditInfo([]string{clientIP, "client %s", "session %s", "deleted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID))
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID))
})
}

View File

@@ -8,12 +8,20 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/header"
)
func TestCreateOauthToken(t *testing.T) {
func TestCreateOAuthToken(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -35,10 +43,37 @@ func TestCreateOauthToken(t *testing.T) {
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("InvalidClientID", func(t *testing.T) {
t.Run("PublicMode", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
"scope": {"metrics"},
}
req, _ := http.NewRequest(method, path, strings.NewReader(data.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
t.Logf("Header: %s", w.Header())
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("InvalidClientID", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -60,10 +95,12 @@ func TestCreateOauthToken(t *testing.T) {
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("WrongClient", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -85,10 +122,12 @@ func TestCreateOauthToken(t *testing.T) {
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("WrongSecret", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -110,10 +149,12 @@ func TestCreateOauthToken(t *testing.T) {
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("AuthNotEnabled", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -135,10 +176,12 @@ func TestCreateOauthToken(t *testing.T) {
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("UnknownAuthMethod", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
@@ -161,3 +204,62 @@ func TestCreateOauthToken(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
func TestDeleteOAuthToken(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
CreateOAuthToken(router)
DeleteOAuthToken(router)
var tokenPath = "/api/v1/oauth/token"
var logoutPath = "/api/v1/oauth/logout"
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
"scope": {"metrics"},
}
createToken, _ := http.NewRequest("POST", tokenPath, strings.NewReader(data.Encode()))
createToken.Header.Add("Content-Type", "application/x-www-form-urlencoded")
createResp := httptest.NewRecorder()
app.ServeHTTP(createResp, createToken)
t.Logf("Header: %s", createResp.Header())
t.Logf("BODY: %s", createResp.Body.String())
assert.Equal(t, http.StatusOK, createResp.Code)
authToken := gjson.Get(createResp.Body.String(), "access_token").String()
deleteToken, _ := http.NewRequest("POST", logoutPath, nil)
deleteToken.Header.Add(header.AuthToken, authToken)
deleteResp := httptest.NewRecorder()
app.ServeHTTP(deleteResp, deleteToken)
t.Logf("Header: %s", deleteResp.Header())
t.Logf("BODY: %s", deleteResp.Body.String())
assert.Equal(t, http.StatusOK, deleteResp.Code)
})
t.Run("PublicMode", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteOAuthToken(router)
sess := entity.SessionFixtures.Get("alice_token")
deleteToken, _ := http.NewRequest("POST", "/api/v1/oauth/logout", nil)
deleteToken.Header.Add(header.AuthToken, sess.AuthToken())
deleteResp := httptest.NewRecorder()
app.ServeHTTP(deleteResp, deleteToken)
t.Logf("Header: %s", deleteResp.Header())
t.Logf("BODY: %s", deleteResp.Body.String())
assert.Equal(t, http.StatusForbidden, deleteResp.Code)
})
}

View File

@@ -248,6 +248,17 @@ func TestDeleteSession(t *testing.T) {
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AdminAuthenticatedLogout", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
DeleteSession(router)
authToken := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session", authToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("UserWithoutAuthentication", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)

View File

@@ -94,7 +94,8 @@ func UpdateSubject(router *gin.RouterGroup) {
// LikeSubject flags a subject as favorite.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Subject UID
//
// POST /api/v1/subjects/:uid/like
@@ -127,7 +128,8 @@ func LikeSubject(router *gin.RouterGroup) {
// DislikeSubject removes the favorite flag from a subject.
//
// Request Parameters:
// The request parameters are:
//
// - uid: string Subject UID
//
// DELETE /api/v1/subjects/:uid/like

View File

@@ -18,7 +18,8 @@ import (
// GetThumb returns a thumbnail image matching the file hash, crop area, and type.
//
// Request Parameters:
// The request parameters are:
//
// - thumb: string sha1 file hash plus optional crop area
// - token: string url security token, see config
// - size: string thumb type, see thumb.Sizes

View File

@@ -19,7 +19,8 @@ import (
// GetVideo streams video content.
//
// Request Parameters:
// The request parameters are:
//
// - hash: string The photo or video file hash as returned by the search API
// - type: string Video format
//

View File

@@ -619,6 +619,7 @@ func (m *Session) Valid() bool {
if m.AuthMethod == authn.MethodOAuth2.String() {
return true
}
return m.User().IsRegistered() || m.IsVisitor() && m.HasShares()
}

View File

@@ -15,24 +15,30 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// handler for the path with (without) the trailing slash exists.
router.RedirectTrailingSlash = true
// Static assets and templates.
// Register static asset and templates routes.
registerStaticRoutes(router, conf)
// Web app bootstrapping and configuration.
// Register PWA bootstrap and config routes.
registerPWARoutes(router, conf)
// Built-in WebDAV server.
// Register built-in WebDAV server routes.
registerWebDAVRoutes(router, conf)
// Sharing routes start with "/s".
// Register sharing routes starting with "/s".
registerSharingRoutes(router, conf)
// JSON-REST API Version 1
// Register ".well-known" service discovery routes.
registerWellknownRoutes(router, conf)
// Register JSON REST-API version 1 (APIv1) routes, grouped by functionality.
// Docs: https://pkg.go.dev/github.com/photoprism/photoprism/internal/api
// Authentication.
api.CreateSession(APIv1)
api.GetSession(APIv1)
api.DeleteSession(APIv1)
api.CreateOauthToken(APIv1)
api.CreateOAuthToken(APIv1)
api.DeleteOAuthToken(APIv1)
// Server Config.
api.GetConfigOptions(APIv1)

View File

@@ -0,0 +1,39 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/server/wellknown"
)
// registerWellknownRoutes configures the ".well-known" service discovery routes.
func registerWellknownRoutes(router *gin.Engine, conf *config.Config) {
router.Any(conf.BaseUri("/.well-known/oauth-authorization-server"), func(c *gin.Context) {
response := &wellknown.OAuthAuthorizationServer{
Issuer: conf.SiteUrl(),
TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()),
EndSessionEndpoint: fmt.Sprintf("%sapi/v1/oauth/logout", conf.SiteUrl()),
ScopesSupported: acl.Resources.Resources(),
ResponseTypesSupported: []string{"token"},
GrantTypesSupported: []string{"client_credentials"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ResponseModesSupported: []string{},
SubjectTypesSupported: []string{},
ClaimsSupported: []string{},
CodeChallengeMethodsSupported: []string{},
IntrospectionEndpointAuthMethodsSupported: []string{},
RevocationEndpointAuthMethodsSupported: []string{},
RequestParameterSupported: false,
RequestObjectSigningAlgValuesSupported: []string{},
DeviceAuthorizationEndpoint: "",
DpopSigningAlgValuesSupported: []string{},
}
c.JSON(http.StatusOK, response)
})
}

View File

@@ -0,0 +1,26 @@
package wellknown
// OAuthAuthorizationServer represents the values returned by the "/.well-known/oauth-authorization-server" endpoint.
type OAuthAuthorizationServer struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
RevocationEndpoint string `json:"revocation_endpoint"`
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"`
EndSessionEndpoint string `json:"end_session_endpoint"`
RequestParameterSupported bool `json:"request_parameter_supported"`
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
}

View File

@@ -0,0 +1,25 @@
/*
Package wellknown provides data types and abstractions for service discovery endpoints.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package wellknown

View File

@@ -9,7 +9,7 @@ import (
// Get returns an existing client session.
func (s *Session) Get(id string) (m *entity.Session, err error) {
if id == "" {
return &entity.Session{}, fmt.Errorf("new")
return &entity.Session{}, fmt.Errorf("invalid session id")
}
return entity.FindSession(id)

View File

@@ -8,7 +8,7 @@ import (
// Attr represents a list of key-value attributes.
type Attr []*KeyValue
// ParseAttr parses a string into a new Attr since and returns it.
// ParseAttr parses a string into a new Attr slice and returns it.
func ParseAttr(s string) Attr {
fields := strings.Fields(s)
result := make(Attr, 0, len(fields))

View File

@@ -8,23 +8,27 @@ import (
func TestParseAttr(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
f := ParseAttr("")
assert.Len(t, f, 0)
assert.Equal(t, Attr{}, f)
attr := ParseAttr("")
assert.Len(t, attr, 0)
assert.Equal(t, Attr{}, attr)
})
t.Run("Keys", func(t *testing.T) {
f := ParseAttr("foo bar baz")
assert.Len(t, f, 3)
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, f)
attr := ParseAttr("foo bar baz")
assert.Len(t, attr, 3)
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr)
})
t.Run("WhitespaceKeys", func(t *testing.T) {
f := ParseAttr(" foo bar baz ")
assert.Len(t, f, 3)
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, f)
attr := ParseAttr(" foo bar baz ")
assert.Len(t, attr, 3)
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr)
})
t.Run("Values", func(t *testing.T) {
f := ParseAttr("foo:yes bar:disable baz:true biZZ:false BIG CAT:FISH berghain:berlin:germany hello:off")
assert.Len(t, f, 8)
attr := ParseAttr("foo:yes bar:disable baz:true biZZ:false BIG CAT:FISH berghain:berlin:germany hello:off")
assert.Len(t, attr, 8)
assert.Equal(t,
Attr{
{Key: "foo", Value: "true"},
@@ -35,52 +39,75 @@ func TestParseAttr(t *testing.T) {
{Key: "CAT", Value: "FISH"},
{Key: "berghain", Value: "berlin:germany"},
{Key: "hello", Value: "false"},
}, f,
}, attr,
)
})
t.Run("Scopes", func(t *testing.T) {
attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false")
assert.Len(t, attr, 7)
assert.Equal(t, Attr{
{Key: "files", Value: "true"},
{Key: "files.read", Value: "true"},
{Key: "photos", Value: "true"},
{Key: "photos.create", Value: "false"},
{Key: "albums", Value: "true"},
{Key: "people.view", Value: "true"},
{Key: "config.view", Value: "false"},
}, attr)
})
}
func TestParseKeyValue(t *testing.T) {
t.Run("Any", func(t *testing.T) {
kv := ParseKeyValue("*")
assert.Equal(t, "*", kv.Key)
assert.Equal(t, "true", kv.Value)
})
t.Run("Scope", func(t *testing.T) {
kv := ParseKeyValue("files.read:true")
t.Logf("Scope: %#v", kv)
assert.Equal(t, &KeyValue{Key: "files.read", Value: "true"}, kv)
})
}
func TestAttr_String(t *testing.T) {
t.Run("SlackScope", func(t *testing.T) {
s := "admin.conversations.removeCustomRetention admin.usergroups:read"
f := ParseAttr(s)
attr := ParseAttr(s)
expected := Attr{
{Key: "admin.conversations.removeCustomRetention", Value: "true"},
{Key: "admin.usergroups", Value: "read"},
}
assert.Len(t, f, 2)
assert.Equal(t, expected, f)
assert.Equal(t, s, f.String())
assert.Len(t, attr, 2)
assert.Equal(t, expected, attr)
assert.Equal(t, s, attr.String())
})
t.Run("Random", func(t *testing.T) {
s := " admin.conversations.removeCustomRetention admin.usergroups:read me:yes FOOt0-2U 6VU #$#%$ cm,Nu"
f := ParseAttr(s)
attr := ParseAttr(s)
assert.Len(t, f, 6)
assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me", f.String())
assert.Len(t, attr, 6)
assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me", attr.String())
})
}
func TestAttr_Contains(t *testing.T) {
t.Run("Any", func(t *testing.T) {
s := "*"
a := ParseAttr(s)
attr := ParseAttr(s)
assert.Len(t, a, 1)
assert.Len(t, attr, 1)
assert.True(t, attr.Contains("metrics"))
})
t.Run("Scopes", func(t *testing.T) {
attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false")
t.Logf("Contains: %s", a[0])
assert.True(t, a.Contains("metrics"))
})
}
func TestParseKeyValue(t *testing.T) {
t.Run("Any", func(t *testing.T) {
v := ParseKeyValue("*")
t.Logf("Key: '%s'", v.Key)
t.Logf("Value: '%s'", v.Value)
assert.Equal(t, "*", v.Key)
assert.Equal(t, "true", v.Value)
assert.Len(t, attr, 7)
assert.True(t, attr.Contains("people.view"))
})
}