photoprism/internal/api/session_oauth.go
Michael Mayer 2912ac9464 Security: Refactor cache headers, auth token validation & UI #782 #808
Signed-off-by: Michael Mayer <michael@photoprism.app>
2024-01-17 14:16:02 +01:00

218 lines
8.3 KiB
Go

package api
import (
"net/http"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"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/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/rnd"
)
// 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) {
router.POST("/oauth/token", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.
if header.IsCdn(c.Request) {
AbortNotFound(c)
return
}
// Get client IP address for logs and rate limiting checks.
clientIp := ClientIP(c)
if get.Config().Public() {
// Abort if running in public mode.
event.AuditErr([]string{clientIp, "create client session", "disabled in public mode"})
AbortForbidden(c)
return
}
var err error
// Client authentication request credentials.
var f form.ClientCredentials
// Allow authentication with basic auth and form values.
if clientId, clientSecret, _ := header.BasicAuth(c); clientId != "" && clientSecret != "" {
f.ClientID = clientId
f.ClientSecret = clientSecret
} else if err = c.ShouldBind(&f); err != nil {
event.AuditWarn([]string{clientIp, "create client session", "%s"}, err)
AbortBadRequest(c)
return
}
// Check the credentials for completeness and the correct format.
if err = f.Validate(); err != nil {
event.AuditWarn([]string{clientIp, "create client session", "%s"}, err)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
}
// Disable caching of responses.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Fail if authentication error rate limit is exceeded.
if clientIp != "" && (limiter.Login.Reject(clientIp) || limiter.Auth.Reject(clientIp)) {
limiter.AbortJSON(c)
return
}
// Find the client that has the ID specified in the authentication request.
client := entity.FindClient(f.ClientID)
// Abort if the client ID or secret are invalid.
if client == nil {
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 session", "authentication disabled"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} 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 session", "invalid client secret"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
limiter.Login.Reserve(clientIp)
return
}
// Create new client session.
sess := client.NewSession(c)
// 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 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 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", "created"}, f.ClientID, sess.RefID)
}
// Deletes old client sessions above the configured limit.
if deleted := client.EnforceAuthTokenLimit(); deleted > 0 {
event.AuditInfo([]string{clientIp, "client %s", "%s deleted"}, f.ClientID, english.Plural(deleted, "old session", "old sessions"))
}
// Response includes access token, token type, and token lifetime.
data := gin.H{
"access_token": sess.AuthToken(),
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
}
// Return JSON response.
c.JSON(http.StatusOK, data)
})
}
// RevokeOAuthToken takes an access token and deletes it. A client may only delete its own tokens.
//
// POST /api/v1/oauth/revoke
func RevokeOAuthToken(router *gin.RouterGroup) {
router.POST("/oauth/revoke", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.
if header.IsCdn(c.Request) {
AbortNotFound(c)
return
}
// 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", "disabled in public mode"})
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
var err error
// Token revocation request data.
var f form.ClientToken
authToken := AuthToken(c)
// Get the auth token to be revoked from the submitted form values or the request header.
if err = c.ShouldBind(&f); err != nil && authToken == "" {
event.AuditWarn([]string{clientIp, "delete client session", "%s"}, err)
AbortBadRequest(c)
return
} else if f.Empty() {
f.AuthToken = authToken
f.TypeHint = form.ClientAccessToken
}
// Check the token form values.
if err = f.Validate(); err != nil {
event.AuditWarn([]string{clientIp, "delete client session", "%s"}, err)
AbortBadRequest(c)
return
}
// Disable caching of responses.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Find session based on auth token.
sess, err := entity.FindSession(rnd.SessionID(f.AuthToken))
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))
})
}