/
githubmirror
/
photoprism
Обзор
Документация
Войти
/
githubmirror
/
photoprism
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
internal/api/oauth_token.go
251 строка
9 KB
Michael Mayer
Auth: Gate app passwords behind a settings feature flag #5647
09 июн 2026, 20:36
09 июн 2026, 20:36
cb48c20
Код
Авторство
О чём код?
package api import ( "errors" "fmt" "net/http" "github.com/dustin/go-humanize/english" "github.com/gin-gonic/gin" "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/http/header" "github.com/photoprism/photoprism/pkg/log/status" ) // OAuthToken creates a new access token for clients using OAuth2 grant types. // // @Summary create an OAuth2 access token // @Id OAuthToken // @Tags Authentication // @Accept json // @Produce json // @Param request body form.OAuthCreateToken true "token request (supports client_credentials, password, or session grant)" // @Success 200 {object} gin.H // @Failure 400,401,403,429 {object} i18n.Response // @Router /api/v1/oauth/token [post] func OAuthToken(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) actor := "unknown client" action := "create token" // Abort if running in public mode. if get.Config().Public() { event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrDisabledInPublicMode.Error()}) AbortForbidden(c) return } // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) // The OIDC authorization_code grant is handled by the Portal OIDC OP, which // parses and validates the request itself. Delegating here keeps a single // OIDC-compliant token_endpoint while the client_credentials, password, and // session grants below stay unchanged and DB-backed. The check runs before // the form binding because form.OAuthCreateToken.Validate rejects this grant // and a client_secret_basic request would otherwise be read as // client_credentials. It is gated on a form content type so that peeking at // the body here does not consume it in a way that masks the missing-body // error the binding below relies on, and because per RFC 6749 the token // endpoint is always form-encoded. Builds without the OP report the grant as // unsupported. if c.ContentType() == header.ContentTypeForm && authn.Grant(c.PostForm("grant_type")) == authn.GrantAuthorizationCode { if OAuthAuthorizationCodeHandler != nil { OAuthAuthorizationCodeHandler(c) return } event.AuditWarn([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidGrantType.Error()}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "unsupported_grant_type", "error_description": "grant_type is not supported", }) return } // Token create request form. var frm form.OAuthCreateToken var sess *entity.Session var client *entity.Client var err error // Allow authentication with basic auth and form values. if clientId, clientSecret, _ := header.BasicAuth(c); clientId != "" && clientSecret != "" { frm.GrantType = authn.GrantClientCredentials frm.ClientID = clientId frm.ClientSecret = clientSecret } else if err = c.ShouldBind(&frm); err != nil { event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)}) AbortBadRequest(c, err) return } // Check the credentials for completeness and the correct format. if err = frm.Validate(); err != nil { event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)}) AbortInvalidCredentials(c) return } // Check request rate limit. r := limiter.Login.Request(clientIp) // Abort if request rate limit is exceeded. if r.Reject() || limiter.Auth.Reject(clientIp) { limiter.AbortJSON(c) return } switch { case frm.ClientID != "": actor = fmt.Sprintf("client %s", clean.Log(frm.ClientID)) case frm.Username != "": actor = fmt.Sprintf("user %s", clean.Log(frm.Username)) case frm.GrantType == authn.GrantPassword: actor = "unknown user" } // Create a new session (access token) based on the grant type specified in the request. switch frm.GrantType { case authn.GrantClientCredentials, authn.GrantUndefined: // Find client with the specified ID. client = entity.FindClientByUID(frm.ClientID) // Check if a client has been found, it is enabled, and the credentials are valid. if client == nil { event.AuditWarn([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidClientID.Error()}) AbortInvalidCredentials(c) return } else if !client.AuthEnabled { event.AuditWarn([]string{clientIp, "oauth2", actor, action, authn.ErrAuthenticationDisabled.Error()}) AbortInvalidCredentials(c) return } else if method := client.Method(); !method.IsDefault() && method != authn.MethodOAuth2 { event.AuditWarn([]string{clientIp, "oauth2", actor, action, "method %s", status.Unsupported}, clean.LogQuote(method.String())) AbortInvalidCredentials(c) return } else if client.InvalidSecret(frm.ClientSecret) { event.AuditWarn([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidClientSecret.Error()}) AbortInvalidCredentials(c) return } // Update time of last activity. client.UpdateLastActive(true) // Cancel failure rate limit reservation. r.Success() // Create new client session. sess = client.NewSession(c, authn.GrantClientCredentials) case authn.GrantPassword, authn.GrantSession: // Reject minting app passwords when the feature is disabled. if get.Config().DisableAppPasswords() { event.AuditWarn([]string{clientIp, "oauth2", actor, action, "app passwords disabled", status.Denied}) AbortFeatureDisabled(c) return } // Generate an app password for a user account and check the password for confirmation. s := Session(clientIp, AuthToken(c)) if s == nil { AbortInvalidCredentials(c) return } else if s.GetUserName() == "" || s.IsClient() || !s.IsRegistered() { event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidGrantType.Error()}) AbortInvalidCredentials(c) return } actor = fmt.Sprintf("user %s", clean.Log(s.GetUserName())) if s.GetUser().Provider().SupportsPasswordAuthentication() { loginForm := form.Login{ Username: s.GetUserName(), Password: frm.Password, } authUser, authProvider, authMethod, authErr := entity.Auth(loginForm, nil, c) switch { case authProvider.IsClient(): event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Denied}) AbortInvalidCredentials(c) return case authMethod.Is(authn.Method2FA) && errors.Is(authErr, authn.ErrPasscodeRequired): // Ok. case authErr != nil: event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Error(authErr)}) AbortInvalidCredentials(c) return case !authUser.Equal(s.GetUser()): event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrUserDoesNotMatch.Error()}) AbortInvalidCredentials(c) return } frm.GrantType = authn.GrantPassword } else { frm.GrantType = authn.GrantSession } sess = entity.NewClientSession(frm.ClientName, frm.ExpiresIn, frm.Scope, frm.GrantType, s.GetUser()) // Return the reserved request rate limit tokens after successful authentication. r.Success() default: event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidGrantType.Error()}) AbortInvalidCredentials(c) return } // Save new session. if sess, err = get.Session().Save(sess); err != nil { event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Error(err)}) AbortInvalidCredentials(c) return } else if sess == nil { event.AuditErr([]string{clientIp, "oauth2", actor, action, StatusFailed.String()}) AbortUnexpectedError(c) return } else { event.AuditInfo([]string{clientIp, "oauth2", actor, action, status.Created}) } // Delete any existing client sessions above the configured limit. if client == nil { // Skip deletion if not created by a client. } else if deleted := client.EnforceAuthTokenLimit(); deleted > 0 { event.AuditInfo([]string{clientIp, "oauth2", actor, action, "deleted %s to enforce token limit"}, english.Plural(deleted, "session", "sessions")) } // Send response with access token, token type, and token lifetime. response := gin.H{ "status": StatusSuccess, "session_id": sess.ID, "access_token": sess.AuthToken(), "token_type": sess.AuthTokenType(), "expires_in": sess.ExpiresIn(), "client_name": sess.GetClientName(), "client_role": sess.GetClientRole(), "scope": sess.Scope(), } c.JSON(http.StatusOK, response) }) }