mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
356 lines
12 KiB
Go
356 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/dustin/go-humanize/english"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
|
"github.com/photoprism/photoprism/internal/entity"
|
|
"github.com/photoprism/photoprism/internal/event"
|
|
"github.com/photoprism/photoprism/internal/form"
|
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
|
"github.com/photoprism/photoprism/internal/server/limiter"
|
|
"github.com/photoprism/photoprism/pkg/authn"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/i18n"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/service/http/header"
|
|
)
|
|
|
|
// CreateUserPasscode sets up a new two-factor authentication passcode for a user.
|
|
//
|
|
// @Summary create a new 2FA passcode for a user
|
|
// @Id CreateUserPasscode
|
|
// @Tags Users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param uid path string true "user uid"
|
|
// @Param request body form.Passcode true "passcode setup (password required)"
|
|
// @Success 200 {object} entity.Passcode
|
|
// @Failure 400,401,403,429 {object} i18n.Response
|
|
// @Router /api/v1/users/{uid}/passcode [post]
|
|
func CreateUserPasscode(router *gin.RouterGroup) {
|
|
router.POST("/users/:uid/passcode", func(c *gin.Context) {
|
|
// Check authentication and authorization.
|
|
s, user, frm, authErr := checkUserPasscodeAuth(c, acl.ActionCreate)
|
|
|
|
if authErr != nil {
|
|
return
|
|
}
|
|
|
|
// Get client IP address for logs and rate limiting checks.
|
|
clientIp := ClientIP(c)
|
|
|
|
// Check request rate limit.
|
|
r := limiter.Login.Request(clientIp)
|
|
|
|
if r.Reject() {
|
|
limiter.AbortJSON(c)
|
|
return
|
|
}
|
|
|
|
// Check user password and abort if invalid.
|
|
if code, msg, err := checkUserPasscodePassword(c, user, frm.Password); err != nil {
|
|
event.AuditErr([]string{clientIp, "session %s", authn.Users, user.UserName, authn.ErrPasscodeGenerateFailed.Error(), strings.ToLower(clean.Error(err))}, s.RefID)
|
|
Abort(c, code, msg)
|
|
return
|
|
}
|
|
|
|
// Return the reserved request rate limit tokens after successful authentication.
|
|
r.Success()
|
|
|
|
// Get config.
|
|
conf := get.Config()
|
|
|
|
// Generate and save new passcode key.
|
|
var passcode *entity.Passcode
|
|
if key, err := rnd.AuthKey(conf.AppName(), user.UserName); err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeGenerateFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
|
|
return
|
|
} else if passcode, err = entity.NewPasscode(user.GetUID(), key.String(), rnd.RecoveryCode()); err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeCreateFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
|
|
return
|
|
} else if err = passcode.Save(); err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeSaveFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusConflict, i18n.ErrSaveFailed)
|
|
return
|
|
}
|
|
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Created}, s.RefID)
|
|
|
|
c.JSON(http.StatusOK, passcode)
|
|
})
|
|
}
|
|
|
|
// ConfirmUserPasscode verifies a newly created passcode so that it can be activated.
|
|
//
|
|
// @Summary verify a new 2FA passcode
|
|
// @Id ConfirmUserPasscode
|
|
// @Tags Users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param uid path string true "user uid"
|
|
// @Param request body form.Passcode true "verification code"
|
|
// @Success 200 {object} entity.Passcode
|
|
// @Failure 400,401,403,429 {object} i18n.Response
|
|
// @Router /api/v1/users/{uid}/passcode/confirm [post]
|
|
func ConfirmUserPasscode(router *gin.RouterGroup) {
|
|
router.POST("/users/:uid/passcode/confirm", func(c *gin.Context) {
|
|
// Check authentication and authorization.
|
|
s, user, frm, authErr := checkUserPasscodeAuth(c, acl.ActionUpdate)
|
|
|
|
if authErr != nil {
|
|
return
|
|
}
|
|
|
|
// Check request rate limit.
|
|
r := limiter.Login.RequestN(ClientIP(c), 3)
|
|
|
|
if r.Reject() {
|
|
limiter.AbortJSON(c)
|
|
return
|
|
}
|
|
|
|
// Verify passcode.
|
|
valid, passcode, err := user.VerifyPasscode(frm.Passcode())
|
|
|
|
if err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeVerificationFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusForbidden, i18n.ErrInvalidPasscode)
|
|
return
|
|
} else if !valid {
|
|
event.AuditWarn([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrInvalidPasscode.Error()}, s.RefID)
|
|
Abort(c, http.StatusForbidden, i18n.ErrInvalidPasscode)
|
|
return
|
|
}
|
|
|
|
// Return the reserved request rate limit tokens after successful authentication.
|
|
r.Success()
|
|
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Verified}, s.RefID)
|
|
|
|
// Clear session cache.
|
|
s.ClearCache()
|
|
|
|
c.JSON(http.StatusOK, passcode)
|
|
})
|
|
}
|
|
|
|
// ActivateUserPasscode activates 2FA after a passcode has been created and verified.
|
|
//
|
|
// @Summary activate 2FA with a verified passcode
|
|
// @Id ActivateUserPasscode
|
|
// @Tags Users
|
|
// @Produce json
|
|
// @Param uid path string true "user uid"
|
|
// @Success 200 {object} entity.Passcode
|
|
// @Failure 401,403,404,429 {object} i18n.Response
|
|
// @Router /api/v1/users/{uid}/passcode/activate [post]
|
|
func ActivateUserPasscode(router *gin.RouterGroup) {
|
|
router.POST("/users/:uid/passcode/activate", func(c *gin.Context) {
|
|
// Check authentication and authorization.
|
|
s, user, _, authErr := checkUserPasscodeAuth(c, acl.ActionUpdate)
|
|
|
|
if authErr != nil {
|
|
return
|
|
}
|
|
|
|
// Activate new passcode.
|
|
passcode, err := user.ActivatePasscode()
|
|
|
|
if err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeActivationFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusForbidden, i18n.ErrSaveFailed)
|
|
return
|
|
}
|
|
|
|
// Log event.
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Activated}, s.RefID)
|
|
|
|
// Invalidate any other user sessions to protect the account:
|
|
// https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, "invalidated %s"}, s.RefID,
|
|
english.Plural(user.DeleteSessions([]string{s.ID}), authn.Session, authn.Sessions))
|
|
|
|
// Clear session cache.
|
|
s.ClearCache()
|
|
|
|
c.JSON(http.StatusOK, passcode)
|
|
})
|
|
}
|
|
|
|
// DeactivateUserPasscode removes a passcode key to disable two-factor authentication.
|
|
//
|
|
// @Summary deactivate 2FA and remove the passcode
|
|
// @Id DeactivateUserPasscode
|
|
// @Tags Users
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param uid path string true "user uid"
|
|
// @Param request body form.Passcode true "password for confirmation"
|
|
// @Success 200 {object} i18n.Response
|
|
// @Failure 400,401,403,404,429 {object} i18n.Response
|
|
// @Router /api/v1/users/{uid}/passcode/deactivate [post]
|
|
func DeactivateUserPasscode(router *gin.RouterGroup) {
|
|
router.POST("/users/:uid/passcode/deactivate", func(c *gin.Context) {
|
|
// Check authentication and authorization.
|
|
s, user, frm, authErr := checkUserPasscodeAuth(c, acl.ActionDelete)
|
|
|
|
if authErr != nil {
|
|
return
|
|
}
|
|
|
|
// Get client IP address for logs and rate limiting checks.
|
|
clientIp := ClientIP(c)
|
|
|
|
// Check request rate limit.
|
|
r := limiter.Login.Request(clientIp)
|
|
|
|
if r.Reject() {
|
|
limiter.AbortJSON(c)
|
|
return
|
|
}
|
|
|
|
// Check user password and abort if invalid.
|
|
if code, msg, err := checkUserPasscodePassword(c, user, frm.Password); err != nil {
|
|
event.AuditErr([]string{clientIp, "session %s", authn.Users, user.UserName, authn.ErrPasscodeDeactivationFailed.Error(), strings.ToLower(clean.Error(err))}, s.RefID)
|
|
Abort(c, code, msg)
|
|
return
|
|
}
|
|
|
|
// Return the reserved request rate limit tokens after successful authentication.
|
|
r.Success()
|
|
|
|
// Delete passcode.
|
|
if _, err := user.DeactivatePasscode(); err != nil {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.ErrPasscodeDeactivationFailed.Error(), clean.Error(err)}, s.RefID)
|
|
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Deactivated}, s.RefID)
|
|
|
|
// Clear session cache.
|
|
s.ClearCache()
|
|
|
|
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSettingsSaved))
|
|
})
|
|
}
|
|
|
|
// checkUserPasscodeAuth checks authentication and authorization for the passcode endpoints.
|
|
func checkUserPasscodeAuth(c *gin.Context, action acl.Permission) (*entity.Session, *entity.User, *form.Passcode, error) {
|
|
conf := get.Config()
|
|
|
|
// Prevent caching of API response.
|
|
c.Header(header.CacheControl, header.CacheControlNoStore)
|
|
|
|
// You cannot change any passwords without authentication and settings enabled.
|
|
if conf.Public() || conf.DisableSettings() {
|
|
Abort(c, http.StatusForbidden, i18n.ErrPublic)
|
|
return nil, nil, nil, authn.ErrPasscodeNotSupported
|
|
}
|
|
|
|
// Check limit for failed auth requests (max. 10 per minute).
|
|
if limiter.Login.Reject(ClientIP(c)) {
|
|
limiter.AbortJSON(c)
|
|
return nil, nil, nil, authn.ErrRateLimitExceeded
|
|
}
|
|
|
|
// Get session.
|
|
s := Auth(c, acl.ResourcePasscode, action)
|
|
|
|
if s.Abort(c) {
|
|
return s, nil, nil, authn.ErrUnauthorized
|
|
}
|
|
|
|
// Check if the current user has management privileges.
|
|
uid := clean.UID(c.Param("uid"))
|
|
|
|
// Get user from session.
|
|
user := s.GetUser()
|
|
|
|
// Regular users can only set up a passcode for their own account.
|
|
if user.UserUID != uid || !user.CanLogIn() {
|
|
AbortForbidden(c)
|
|
return s, nil, nil, authn.ErrUnauthorized
|
|
}
|
|
|
|
// Check if the user's authentication provider supports 2FA passcodes.
|
|
if !user.Provider().SupportsPasscodeAuthentication() {
|
|
Abort(c, http.StatusForbidden, i18n.ErrUnsupported)
|
|
return s, nil, nil, authn.ErrPasscodeNotSupported
|
|
}
|
|
|
|
frm := &form.Passcode{}
|
|
|
|
// Validate request form values.
|
|
if err := c.BindJSON(frm); err != nil {
|
|
Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword)
|
|
return s, nil, nil, authn.ErrInvalidRequest
|
|
} else if authn.KeyTOTP.NotEqual(frm.Type) {
|
|
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedType)
|
|
return s, nil, nil, authn.ErrInvalidPasscodeType
|
|
}
|
|
|
|
return s, user, frm, nil
|
|
}
|
|
|
|
// checkUserPasscodePassword checks if the specified password is valid.
|
|
func checkUserPasscodePassword(c *gin.Context, user *entity.User, password string) (code int, msg i18n.Message, err error) {
|
|
// Set result defaults.
|
|
code = http.StatusForbidden
|
|
msg = i18n.ErrInvalidPassword
|
|
|
|
if user == nil {
|
|
return code, msg, authn.ErrUserRequired
|
|
} else if c == nil {
|
|
return code, msg, authn.ErrContextRequired
|
|
}
|
|
|
|
username := user.Username()
|
|
|
|
if username == "" {
|
|
return code, msg, authn.ErrUsernameRequired
|
|
}
|
|
|
|
switch user.Provider() {
|
|
// Check local account password.
|
|
case authn.ProviderLocal:
|
|
if user.InvalidPassword(password) {
|
|
return code, msg, authn.ErrInvalidPassword
|
|
}
|
|
// Use generic authentication check.
|
|
default:
|
|
// Create user login form.
|
|
f := form.Login{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
|
|
// Check if user login credentials are valid.
|
|
if authUser, provider, method, authErr := entity.Auth(f, nil, c); method == authn.Method2FA && errors.Is(authErr, authn.ErrPasscodeRequired) {
|
|
return http.StatusOK, i18n.MsgVerified, nil
|
|
} else if authErr != nil {
|
|
// Abort if authentication has failed otherwise.
|
|
return code, msg, authErr
|
|
} else if authUser == nil {
|
|
// Abort if account was not found.
|
|
return code, msg, authn.ErrAccountNotFound
|
|
} else if !authUser.Equal(user) {
|
|
// Abort if user accounts do not match.
|
|
return code, msg, authn.ErrUserDoesNotMatch
|
|
} else if !provider.SupportsPasscodeAuthentication() || method != authn.MethodDefault {
|
|
// Abort if e.g. an app password was provided.
|
|
return code, msg, authn.ErrInvalidCredentials
|
|
}
|
|
}
|
|
|
|
return http.StatusOK, i18n.MsgVerified, nil
|
|
}
|