Files
photoprism/internal/api/oidc_redirect.go
2025-09-22 04:12:02 +02:00

382 lines
16 KiB
Go

package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/oidc"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/internal/thumb/avatar"
"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"
"github.com/photoprism/photoprism/pkg/time/tz"
"github.com/photoprism/photoprism/pkg/time/unix"
"github.com/photoprism/photoprism/pkg/txt"
)
// OIDCRedirect completes the OIDC flow, creates a session, and renders a page that stores the token client-side.
//
// @Summary complete OIDC login (callback)
// @Id OIDCRedirect
// @Tags Authentication
// @Produce html
// @Param state query string true "opaque OAuth2 state value"
// @Param code query string true "authorization code"
// @Success 200 {string} string "HTML page bootstrapping token storage"
// @Failure 401,403,429 {string} string "rendered error page"
// @Router /api/v1/oidc/redirect [get]
func OIDCRedirect(router *gin.RouterGroup) {
router.GET("/oidc/redirect", func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.
if header.IsCdn(c.Request) {
AbortNotFound(c)
return
}
// Disable caching of responses.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Get client IP address for logs and rate limiting checks.
clientIp := ClientIP(c)
userAgent := UserAgent(c)
userName := "unknown user"
// Get global config.
conf := get.Config()
// Abort in public mode and if OIDC is disabled.
if get.Config().Public() {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrDisabledInPublicMode.Error()})
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
return
} else if !conf.OIDCEnabled() {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthenticationDisabled.Error()})
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
return
}
// Check request rate limit.
var r *limiter.Request
r = limiter.Login.Request(clientIp)
// Abort if failure rate limit is exceeded.
if r.Reject() || limiter.Auth.Reject(clientIp) {
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
return
}
// Check if the required request parameters are present.
if c.Query("state") == "" || c.Query("code") == "" {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthCodeRequired.Error()})
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
return
}
// Get OIDC provider.
provider := get.OIDC()
if provider == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrInvalidProviderConfiguration.Error()})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Check the auth request and, if successful, get user information and tokens.
userInfo, tokens, claimErr := provider.CodeExchangeUserInfo(c)
if claimErr != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", claimErr.Error()})
return
}
// Step 1: Create user account if it does not exist yet.
var user *entity.User
var err error
userEmail := clean.Email(userInfo.Email)
// Optionally check if the email domain matches.
if domain := conf.OIDCDomain(); domain == "" {
// Do nothing.
} else if _, emailDomain, _ := strings.Cut(userEmail, "@"); emailDomain == "" || !userInfo.EmailVerified {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrVerifiedEmailRequired.Error()})
event.LoginError(clientIp, "oidc", userEmail, userAgent, authn.ErrVerifiedEmailRequired.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
return
} else if !strings.HasSuffix("."+emailDomain, "."+domain) {
message := fmt.Sprintf("domain must match '%s'", domain)
event.AuditErr([]string{clientIp, "create session", "oidc", userEmail, message})
event.LoginError(clientIp, "oidc", userEmail, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
return
}
// Find existing user record and update it, if necessary.
if oidcUser := entity.OidcUser(userInfo, provider.Issuer(), oidc.Username(userInfo, conf.OIDCUsername())); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthProviderIsNotOIDC.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if oidcUser.UserName == "" {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrUsernameRequiredToRegister.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequiredToRegister.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if user = entity.FindUser(oidcUser); user != nil {
// Ensure user has a username.
if user.Username() == "" {
event.AuditErr([]string{clientIp, "create session", "oidc", oidcUser.UserName, authn.ErrUsernameRequired.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequired.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
userName = user.Username()
event.AuditInfo([]string{clientIp, "create session", "oidc", "found user", userName})
// Check if the account is enabled and the OIDC Subject ID matches.
if !user.CanLogIn() {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if authn.ProviderOIDC.NotEqual(user.AuthProvider) {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAuthProviderIsNotOIDC.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if user.AuthID == "" || oidcUser.AuthID == "" || user.AuthID != oidcUser.AuthID {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrInvalidAuthID.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrInvalidAuthID.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Update user profile information.
details := user.Details()
// Update user display name.
if entity.SrcPriority[details.NameSrc] <= entity.SrcPriority[entity.SrcOIDC] {
user.SetDisplayName(userInfo.Name, entity.SrcOIDC)
user.SetGivenName(userInfo.GivenName)
user.SetFamilyName(userInfo.FamilyName)
details.UserGender = clean.Name(string(userInfo.Gender))
}
// Update nickname.
if name := clean.Name(userInfo.Nickname); name != "" {
details.NickName = clean.Name(userInfo.Nickname)
}
// Update profile URL.
if u := clean.Uri(userInfo.Profile); u != "" {
details.ProfileURL = u
}
// Update website URL.
if u := clean.Uri(userInfo.Website); u != "" {
details.SiteURL = u
}
// Update UI locale.
user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), user.Settings().UILanguage)
// Update UI timezone.
if zone := userInfo.Zoneinfo; zone != "" && zone != tz.UTC {
user.Settings().UITimeZone = zone
}
// Update user location, if available.
if addr := userInfo.GetAddress(); addr != nil {
user.Details().UserLocation = clean.Name(addr.Locality)
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
}
// Update birthday, if available.
if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
user.BornAt = &birthDate
user.Details().BirthDay = birthDate.Day()
user.Details().BirthMonth = int(birthDate.Month())
user.Details().BirthYear = birthDate.Year()
}
// Update email, if verified.
if userInfo.EmailVerified {
user.UserEmail = clean.Email(userInfo.Email)
user.VerifiedAt = entity.TimeStamp()
}
// Update Subject ID and Issuer URI.
user.SetAuthID(userInfo.Subject, provider.Issuer())
// Update existing user account.
if err = user.Save(); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Set user avatar image?
if avatarUrl := userInfo.Picture; avatarUrl == "" || user.HasAvatar() {
// Do nothing.
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC, conf.ThumbCachePath()); err != nil {
event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
}
} else if conf.UsersQuotaReached(conf.OIDCRole()) {
userName = oidcUser.Username()
event.AuditWarn([]string{clientIp, "create session", "oidc", "create user", userName, authn.ErrUsersQuotaExceeded.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrUsersQuotaExceeded.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrQuotaExceeded)))
return
} else if conf.OIDCRegister() {
// Create new user record.
user = &oidcUser
userName = oidcUser.Username()
// Resolve potential naming conflict by adding a random number to the username.
if found := entity.FindUserByName(userName); found != nil {
userName = userName + rnd.Base10(6)
}
event.AuditInfo([]string{clientIp, "create session", "oidc", "create user", userName})
user.UserName = userName
// Set user profile information.
user.SetDisplayName(userInfo.Name, entity.SrcOIDC)
user.SetGivenName(userInfo.GivenName)
user.SetFamilyName(userInfo.FamilyName)
user.Details().UserGender = clean.Name(string(userInfo.Gender))
user.Details().NickName = clean.Name(userInfo.Nickname)
// Set user profile URL.
user.Details().ProfileURL = clean.Uri(userInfo.Profile)
// Set user site URL.
user.Details().SiteURL = clean.Uri(userInfo.Website)
// Set UI locale.
user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), "")
// Set UI timezone.
user.Settings().UITimeZone = userInfo.Zoneinfo
// Set user location, if available.
if addr := userInfo.GetAddress(); addr != nil {
user.Details().UserLocation = clean.Name(addr.Locality)
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
}
// Set birthday, if available.
if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
user.BornAt = &birthDate
user.Details().BirthDay = birthDate.Day()
user.Details().BirthMonth = int(birthDate.Month())
user.Details().BirthYear = birthDate.Year()
}
// Set email, if verified.
if userInfo.EmailVerified {
user.UserEmail = clean.Email(userInfo.Email)
user.VerifiedAt = entity.TimeStamp()
}
// Set user role and permissions.
user.SetRole(conf.OIDCRole().String())
user.CanLogin = true
user.WebDAV = conf.OIDCWebDAV()
// Create new user account, and then update the Subject ID to make sure it is unique.
if err = user.Create(); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountCreateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountCreateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if err = user.UpdateAuthID(userInfo.Subject, provider.Issuer()); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Set user avatar image.
if avatarUrl := userInfo.Picture; avatarUrl == "" {
event.AuditDebug([]string{clientIp, "create session", "oidc", userName, "no avatar image provided"})
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC, conf.ThumbCachePath()); err != nil {
event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
}
} else {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrRegistrationDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrRegistrationDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Check if login is allowed.
if !user.CanLogIn() {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
}
// Step 2: Create user session.
sess := get.Session().New(c)
sess.SetProvider(authn.ProviderOIDC)
sess.SetMethod(authn.MethodDefault)
sess.SetAuthID(user.AuthID, provider.Issuer())
sess.SetUser(user)
sess.SetGrantType(authn.GrantAuthorizationCode)
sess.IdToken = tokens.IDToken
// Set session expiration and timeout.
sess.SetExpiresIn(unix.Day)
sess.SetTimeout(-1)
// Save session after successful authentication.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, "%s"}, err)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if sess == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.Failed})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
return
}
// Return the reserved request rate limit token after successful authentication.
r.Success()
// Response includes user data, session data, and client config values.
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
// Log session created event.
event.AuditInfo([]string{clientIp, "session %s", "oidc", userName, authn.Created}, sess.RefID)
// Log session expiration time.
if expires := sess.ExpiresAt(); !expires.IsZero() {
event.AuditDebug([]string{clientIp, "session %s", "oidc", userName, "expires at %s"}, sess.RefID, txt.DateTime(&expires))
}
// Log successful login.
event.LoginInfo(clientIp, "oidc", userName, userAgent)
// Update login timestamp.
user.UpdateLoginTime()
// Step 3: Render HTML template to set the access token in localStorage.
c.HTML(http.StatusOK, "auth.gohtml", response)
})
}