2023-12-12 18:42:50 +01:00
|
|
|
package api
|
|
|
|
|
|
|
|
import (
|
|
|
|
"net/http"
|
|
|
|
|
2024-01-08 16:57:07 +01:00
|
|
|
"github.com/dustin/go-humanize/english"
|
2023-12-12 18:42:50 +01:00
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
2024-01-08 14:53:39 +01:00
|
|
|
"github.com/photoprism/photoprism/internal/acl"
|
2023-12-12 18:42:50 +01:00
|
|
|
"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"
|
2024-01-08 16:57:07 +01:00
|
|
|
"github.com/photoprism/photoprism/pkg/authn"
|
2024-01-08 14:53:39 +01:00
|
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
2023-12-12 18:42:50 +01:00
|
|
|
)
|
|
|
|
|
2024-01-08 14:53:39 +01:00
|
|
|
// CreateOAuthToken creates a new access token for clients that
|
2024-01-06 17:35:19 +01:00
|
|
|
// authenticate with valid OAuth2 client credentials.
|
2023-12-12 18:42:50 +01:00
|
|
|
//
|
|
|
|
// POST /api/v1/oauth/token
|
2024-01-08 14:53:39 +01:00
|
|
|
func CreateOAuthToken(router *gin.RouterGroup) {
|
2023-12-12 18:42:50 +01:00
|
|
|
router.POST("/oauth/token", func(c *gin.Context) {
|
2024-01-08 14:53:39 +01:00
|
|
|
// Get client IP address for logs and rate limiting checks.
|
|
|
|
clientIP := ClientIP(c)
|
|
|
|
|
|
|
|
// Abort if running in public mode.
|
|
|
|
if get.Config().Public() {
|
2024-01-08 16:57:07 +01:00
|
|
|
event.AuditErr([]string{clientIP, "create client session", "disabled in public mode"})
|
2024-01-08 14:53:39 +01:00
|
|
|
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-12-12 18:42:50 +01:00
|
|
|
// client_id, client_secret
|
|
|
|
var err error
|
|
|
|
var f form.ClientCredentials
|
|
|
|
|
|
|
|
// Allow authentication with basic auth and form values.
|
|
|
|
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
|
|
|
|
f.ClientID = clientId
|
|
|
|
f.ClientSecret = clientSecret
|
|
|
|
} else if err = c.Bind(&f); err != nil {
|
2024-01-08 16:57:07 +01:00
|
|
|
event.AuditWarn([]string{clientIP, "create client session", "%s"}, err)
|
2023-12-12 18:42:50 +01:00
|
|
|
AbortBadRequest(c)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check the credentials for completeness and the correct format.
|
|
|
|
if err = f.Validate(); err != nil {
|
2024-01-08 16:57:07 +01:00
|
|
|
event.AuditWarn([]string{clientIP, "create client session", "%s"}, err)
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check limit for failed auth requests (max. 10 per minute).
|
|
|
|
if limiter.Login.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 {
|
2024-01-08 14:53:39 +01:00
|
|
|
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_id"}, f.ClientID)
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
|
|
|
limiter.Login.Reserve(clientIP)
|
|
|
|
return
|
|
|
|
} else if !client.AuthEnabled {
|
2024-01-08 16:57:07 +01:00
|
|
|
event.AuditWarn([]string{clientIP, "client %s", "create session", "authentication disabled"}, f.ClientID)
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
|
|
|
return
|
2024-01-08 14:53:39 +01:00
|
|
|
} 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()))
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
|
|
|
return
|
|
|
|
} else if client.WrongSecret(f.ClientSecret) {
|
2024-01-08 14:53:39 +01:00
|
|
|
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_secret"}, f.ClientID)
|
2023-12-12 18:42:50 +01:00
|
|
|
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 {
|
2024-01-08 14:53:39 +01:00
|
|
|
event.AuditErr([]string{clientIP, "client %s", "create session", "%s"}, f.ClientID, err)
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
|
|
|
return
|
|
|
|
} else if sess == nil {
|
2024-01-08 14:53:39 +01:00
|
|
|
event.AuditErr([]string{clientIP, "client %s", "create session", StatusFailed.String()}, f.ClientID)
|
2023-12-12 18:42:50 +01:00
|
|
|
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
|
|
|
|
return
|
|
|
|
} else {
|
2024-01-08 14:53:39 +01:00
|
|
|
event.AuditInfo([]string{clientIP, "client %s", "session %s", "created"}, f.ClientID, sess.RefID)
|
2023-12-12 18:42:50 +01:00
|
|
|
}
|
|
|
|
|
2024-01-08 16:57:07 +01:00
|
|
|
// 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"))
|
|
|
|
}
|
|
|
|
|
2024-01-06 17:35:19 +01:00
|
|
|
// Response includes access token, token type, and token lifetime.
|
2023-12-12 18:42:50 +01:00
|
|
|
data := gin.H{
|
2024-01-06 17:35:19 +01:00
|
|
|
"access_token": sess.AuthToken(),
|
|
|
|
"token_type": sess.AuthTokenType(),
|
2023-12-12 18:42:50 +01:00
|
|
|
"expires_in": sess.ExpiresIn(),
|
|
|
|
}
|
|
|
|
|
2024-01-06 17:35:19 +01:00
|
|
|
// Return JSON response.
|
2023-12-12 18:42:50 +01:00
|
|
|
c.JSON(http.StatusOK, data)
|
|
|
|
})
|
|
|
|
}
|
2024-01-08 14:53:39 +01:00
|
|
|
|
|
|
|
// 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() {
|
2024-01-08 16:57:07 +01:00
|
|
|
event.AuditErr([]string{clientIP, "delete client session", "disabled in public mode"})
|
2024-01-08 14:53:39 +01:00
|
|
|
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))
|
|
|
|
})
|
|
|
|
}
|