mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
OIDC: Upgrade "zitadel/oidc" from v1 to v2 #782
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -9,6 +9,7 @@ services:
|
||||
depends_on:
|
||||
- mariadb
|
||||
- dummy-webdav
|
||||
- dummy-oidc
|
||||
stop_grace_period: 10s
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
|
2
go.mod
2
go.mod
@@ -118,6 +118,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muhlemmer/gu v0.3.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
@@ -128,6 +129,7 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/zitadel/logging v0.5.0 // indirect
|
||||
github.com/zitadel/oidc/v2 v2.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
|
4
go.sum
4
go.sum
@@ -294,6 +294,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
|
||||
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
@@ -381,6 +383,8 @@ github.com/zitadel/logging v0.5.0 h1:Kunouvqse/efXy4UDvFw5s3vP+Z4AlHo3y8wF7stXHA
|
||||
github.com/zitadel/logging v0.5.0/go.mod h1:IzP5fzwFhzzyxHkSmfF8dsyqFsQRJLLcQmwhIBzlGsE=
|
||||
github.com/zitadel/oidc v1.13.5 h1:7jhh68NGZitLqwLiVU9Dtwa4IraJPFF1vS+4UupO93U=
|
||||
github.com/zitadel/oidc v1.13.5/go.mod h1:rHs1DhU3Sv3tnI6bQRVlFa3u0lCwtR7S21WHY+yXgPA=
|
||||
github.com/zitadel/oidc/v2 v2.12.0 h1:4aMTAy99/4pqNwrawEyJqhRb3yY3PtcDxnoDSryhpn4=
|
||||
github.com/zitadel/oidc/v2 v2.12.0/go.mod h1:LrRav74IiThHGapQgCHZOUNtnqJG0tcZKHro/91rtLw=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
|
@@ -29,18 +29,17 @@ func OIDCLogin(router *gin.RouterGroup) {
|
||||
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIp := ClientIP(c)
|
||||
action := "sign in"
|
||||
|
||||
// Get global config.
|
||||
conf := get.Config()
|
||||
|
||||
// Abort in public mode and if OIDC is disabled.
|
||||
if get.Config().Public() {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrDisabledInPublicMode.Error()})
|
||||
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, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthenticationDisabled.Error()})
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||
return
|
||||
}
|
||||
@@ -59,7 +58,7 @@ func OIDCLogin(router *gin.RouterGroup) {
|
||||
provider := get.OIDC()
|
||||
|
||||
if provider == nil {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidProviderConfiguration.Error()})
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrInvalidProviderConfiguration.Error()})
|
||||
c.HTML(http.StatusInternalServerError, "auth.gohtml", CreateSessionError(http.StatusInternalServerError, i18n.Error(i18n.ErrConnectionFailed)))
|
||||
return
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// OIDCRedirect creates a new access token when a user has been successfully authenticated,
|
||||
// OIDCRedirect creates a new API access token when a user has been successfully authenticated via OIDC,
|
||||
// and then redirects the browser back to the app.
|
||||
//
|
||||
// GET /api/v1/oidc/redirect
|
||||
@@ -41,18 +42,17 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
clientIp := ClientIP(c)
|
||||
userAgent := UserAgent(c)
|
||||
userName := "unknown user"
|
||||
action := "sign in"
|
||||
|
||||
// Get global config.
|
||||
conf := get.Config()
|
||||
|
||||
// Abort in public mode and if OIDC is disabled.
|
||||
if get.Config().Public() {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrDisabledInPublicMode.Error()})
|
||||
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, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthenticationDisabled.Error()})
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||
return
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
|
||||
// Check if the required request parameters are present.
|
||||
if c.Query("state") == "" || c.Query("code") == "" {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthCodeRequired.Error()})
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthCodeRequired.Error()})
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||
return
|
||||
}
|
||||
@@ -78,15 +78,16 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
provider := get.OIDC()
|
||||
|
||||
if provider == nil {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||
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, "oidc", action, claimErr.Error()})
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", claimErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,53 +95,55 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
var user *entity.User
|
||||
var err error
|
||||
|
||||
userEmail := clean.Email(userInfo.GetEmail())
|
||||
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.IsEmailVerified() {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrVerifiedEmailRequired.Error()})
|
||||
} 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, "oidc", action, userEmail, message})
|
||||
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, conf.OIDCUsername()); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthProviderIsNotOIDC.Error()})
|
||||
if oidcUser := entity.OidcUser(userInfo, 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, "oidc", action, authn.ErrUsernameRequiredToRegister.Error()})
|
||||
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 {
|
||||
// Check if username and subject UID match.
|
||||
// Ensure user has a username.
|
||||
if user.Username() == "" {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, oidcUser.UserName, authn.ErrUsernameRequired.Error()})
|
||||
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 OIDC subject identifier matches.
|
||||
if authn.ProviderOIDC.NotEqual(user.AuthProvider) {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, authn.ErrAuthProviderIsNotOIDC.Error()})
|
||||
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, "oidc", action, userName, authn.ErrInvalidAuthID.Error()})
|
||||
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
|
||||
@@ -151,43 +154,43 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
|
||||
// Update user display name.
|
||||
if entity.SrcPriority[details.NameSrc] <= entity.SrcPriority[entity.SrcOIDC] {
|
||||
user.SetDisplayName(userInfo.GetName(), entity.SrcOIDC)
|
||||
user.SetGivenName(userInfo.GetGivenName())
|
||||
user.SetFamilyName(userInfo.GetFamilyName())
|
||||
details.UserGender = clean.Name(string(userInfo.GetGender()))
|
||||
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.GetNickname()); name != "" {
|
||||
details.NickName = clean.Name(userInfo.GetNickname())
|
||||
if name := clean.Name(userInfo.Nickname); name != "" {
|
||||
details.NickName = clean.Name(userInfo.Nickname)
|
||||
}
|
||||
|
||||
// Update profile URL.
|
||||
if u := clean.Uri(userInfo.GetProfile()); u != "" {
|
||||
if u := clean.Uri(userInfo.Profile); u != "" {
|
||||
details.ProfileURL = u
|
||||
}
|
||||
|
||||
// Update website URL.
|
||||
if u := clean.Uri(userInfo.GetWebsite()); u != "" {
|
||||
if u := clean.Uri(userInfo.Website); u != "" {
|
||||
details.SiteURL = u
|
||||
}
|
||||
|
||||
// Update UI locale.
|
||||
user.Settings().UILanguage = clean.Locale(userInfo.GetLocale().String(), user.Settings().UILanguage)
|
||||
user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), user.Settings().UILanguage)
|
||||
|
||||
// Update UI timezone.
|
||||
if tz := userInfo.GetZoneinfo(); tz != "" && tz != time.UTC.String() {
|
||||
if tz := userInfo.Zoneinfo; tz != "" && tz != time.UTC.String() {
|
||||
user.Settings().UITimeZone = tz
|
||||
}
|
||||
|
||||
// Update user location, if available.
|
||||
if addr := userInfo.GetAddress(); addr != nil {
|
||||
user.Details().UserLocation = clean.Name(addr.GetLocality())
|
||||
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.GetCountry())
|
||||
user.Details().UserLocation = clean.Name(addr.Locality)
|
||||
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
|
||||
}
|
||||
|
||||
// Update birthday, if available.
|
||||
if birthDate := txt.ParseTime(userInfo.GetBirthdate(), userInfo.GetZoneinfo()); !birthDate.IsZero() {
|
||||
if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
|
||||
user.BornAt = &birthDate
|
||||
user.Details().BirthDay = birthDate.Day()
|
||||
user.Details().BirthMonth = int(birthDate.Month())
|
||||
@@ -195,28 +198,26 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Update email, if verified.
|
||||
if userInfo.IsEmailVerified() {
|
||||
user.UserEmail = clean.Email(userInfo.GetEmail())
|
||||
if userInfo.EmailVerified {
|
||||
user.UserEmail = clean.Email(userInfo.Email)
|
||||
user.VerifiedAt = entity.TimeStamp()
|
||||
}
|
||||
|
||||
// Update existing user account.
|
||||
if err = user.Save(); err != nil {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
|
||||
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.GetPicture(); avatarUrl == "" || user.HasAvatar() {
|
||||
if avatarUrl := userInfo.Picture; avatarUrl == "" || user.HasAvatar() {
|
||||
// Do nothing.
|
||||
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC); err != nil {
|
||||
event.AuditWarn([]string{clientIp, "oidc", action, userName, "failed to set avatar image", err.Error()})
|
||||
event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
|
||||
}
|
||||
} else if conf.OIDCRegister() {
|
||||
action = "register"
|
||||
|
||||
// Create new user record.
|
||||
user = &oidcUser
|
||||
|
||||
@@ -227,35 +228,37 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
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.GetName(), entity.SrcOIDC)
|
||||
user.SetGivenName(userInfo.GetGivenName())
|
||||
user.SetFamilyName(userInfo.GetFamilyName())
|
||||
user.Details().UserGender = clean.Name(string(userInfo.GetGender()))
|
||||
user.Details().NickName = clean.Name(userInfo.GetNickname())
|
||||
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.GetProfile())
|
||||
user.Details().ProfileURL = clean.Uri(userInfo.Profile)
|
||||
|
||||
// Set user site URL.
|
||||
user.Details().SiteURL = clean.Uri(userInfo.GetWebsite())
|
||||
user.Details().SiteURL = clean.Uri(userInfo.Website)
|
||||
|
||||
// Set UI locale.
|
||||
user.Settings().UILanguage = clean.Locale(userInfo.GetLocale().String(), "")
|
||||
user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), "")
|
||||
|
||||
// Set UI timezone.
|
||||
user.Settings().UITimeZone = userInfo.GetZoneinfo()
|
||||
user.Settings().UITimeZone = userInfo.Zoneinfo
|
||||
|
||||
// Set user location, if available.
|
||||
if addr := userInfo.GetAddress(); addr != nil {
|
||||
user.Details().UserLocation = clean.Name(addr.GetLocality())
|
||||
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.GetCountry())
|
||||
user.Details().UserLocation = clean.Name(addr.Locality)
|
||||
user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
|
||||
}
|
||||
|
||||
// Set birthday, if available.
|
||||
if birthDate := txt.ParseTime(userInfo.GetBirthdate(), userInfo.GetZoneinfo()); !birthDate.IsZero() {
|
||||
if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
|
||||
user.BornAt = &birthDate
|
||||
user.Details().BirthDay = birthDate.Day()
|
||||
user.Details().BirthMonth = int(birthDate.Month())
|
||||
@@ -263,8 +266,8 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Set email, if verified.
|
||||
if userInfo.IsEmailVerified() {
|
||||
user.UserEmail = clean.Email(userInfo.GetEmail())
|
||||
if userInfo.EmailVerified {
|
||||
user.UserEmail = clean.Email(userInfo.Email)
|
||||
user.VerifiedAt = entity.TimeStamp()
|
||||
}
|
||||
|
||||
@@ -275,20 +278,20 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
|
||||
// Create new user account.
|
||||
if err = user.Create(); err != nil {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, authn.ErrAccountCreateFailed.Error(), err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
// Set user avatar image.
|
||||
if avatarUrl := userInfo.GetPicture(); avatarUrl == "" {
|
||||
event.AuditDebug([]string{clientIp, "oidc", action, userName, "no avatar image provided"})
|
||||
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); err != nil {
|
||||
event.AuditWarn([]string{clientIp, "oidc", action, userName, "failed to set avatar image", err.Error()})
|
||||
event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
|
||||
}
|
||||
} else {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, authn.ErrRegistrationDisabled.Error()})
|
||||
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
|
||||
@@ -296,7 +299,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
|
||||
// Login allowed?
|
||||
if !user.CanLogIn() {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, authn.ErrAccountDisabled.Error()})
|
||||
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
|
||||
@@ -320,11 +323,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
|
||||
// Save session after successful authentication.
|
||||
if sess, err = get.Session().Save(sess); err != nil {
|
||||
event.AuditErr([]string{clientIp, "oidc", action, userName, "%s"}, err)
|
||||
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, "oidc", action, userName, "session is 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
|
||||
}
|
||||
@@ -335,8 +338,15 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
// Response includes user data, session data, and client config values.
|
||||
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
|
||||
|
||||
// Log success.
|
||||
event.AuditInfo([]string{clientIp, "oidc", action, userName, authn.Succeeded})
|
||||
// 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.
|
||||
|
@@ -5,56 +5,66 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/zitadel/oidc/pkg/client"
|
||||
"github.com/zitadel/oidc/pkg/client/rp"
|
||||
utils "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v2/pkg/client"
|
||||
"github.com/zitadel/oidc/v2/pkg/client/rp"
|
||||
utils "github.com/zitadel/oidc/v2/pkg/http"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleClaim = "photoprism_role"
|
||||
AdminRole = "photoprism_admin"
|
||||
)
|
||||
|
||||
// Client represents an OpenID Connect (OIDC) Relying Party Client.
|
||||
type Client struct {
|
||||
rp.RelyingParty
|
||||
debug bool
|
||||
insecure bool
|
||||
}
|
||||
|
||||
func NewClient(oidcUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl string, debug bool) (result *Client, err error) {
|
||||
u, err := url.Parse(siteUrl)
|
||||
|
||||
if err != nil {
|
||||
log.Debug(err)
|
||||
// NewClient creates and returns a new OpenID Connect (OIDC) Relying Party Client based on the specified parameters.
|
||||
func NewClient(issuerUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl string, insecure bool) (result *Client, err error) {
|
||||
if issuerUri == nil {
|
||||
err = errors.New("issuer uri required")
|
||||
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
|
||||
return nil, errors.New("issuer uri required")
|
||||
} else if insecure == false && issuerUri.Scheme != "https" {
|
||||
err = errors.New("issuer uri must use https")
|
||||
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, config.OidcRedirectUri)
|
||||
// Get redirect URL based on site URL.
|
||||
redirectUrl, urlErr := RedirectURL(siteUrl)
|
||||
|
||||
if urlErr != nil {
|
||||
event.AuditErr([]string{"oidc", "redirect url", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate cryptographic keys.
|
||||
var hashKey, encryptKey []byte
|
||||
|
||||
if hashKey, err = rnd.RandomBytes(16); err != nil {
|
||||
log.Debugf("oidc: %q (create hash key)", err)
|
||||
event.AuditErr([]string{"oidc", "hash key", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if encryptKey, err = rnd.RandomBytes(16); err != nil {
|
||||
log.Debugf("oidc: %q (create encrypt key)", err)
|
||||
event.AuditErr([]string{"oidc", "encrypt key", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create cookie handler.
|
||||
cookieHandler := utils.NewCookieHandler(hashKey, encryptKey, utils.WithUnsecure())
|
||||
httpClient := HttpClient(debug)
|
||||
|
||||
// Create HTTP client.
|
||||
httpClient := HttpClient(insecure)
|
||||
|
||||
// Set OIDC Relying Party client options.
|
||||
clientOpt := []rp.Option{
|
||||
rp.WithHTTPClient(httpClient),
|
||||
rp.WithCookieHandler(cookieHandler),
|
||||
@@ -62,77 +72,80 @@ func NewClient(oidcUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl str
|
||||
rp.WithIssuedAtOffset(5 * time.Second),
|
||||
),
|
||||
rp.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) {
|
||||
log.Debugf("oidc: %s: %s (state: %s)", errorType, errorDesc, state)
|
||||
event.AuditErr([]string{"oidc", "%s", "%s (state %s)"}, errorType, errorDesc, state)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Header().Add("oidc_error", fmt.Sprintf("oidc: %s", errorDesc))
|
||||
}),
|
||||
}
|
||||
|
||||
discover, err := client.Discover(oidcUri.String(), httpClient)
|
||||
// Perform service discovery through the standardized /.well-known/openid-configuration endpoint.
|
||||
discover, err := client.Discover(issuerUri.String(), httpClient)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("oidc: %q (discover)", err)
|
||||
event.AuditErr([]string{"oidc", "provider", "service discovery", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If possible, use Proof of Key Code Exchange (PKCE).
|
||||
for _, v := range discover.CodeChallengeMethodsSupported {
|
||||
if v == oidc.CodeChallengeMethodS256 {
|
||||
clientOpt = append(clientOpt, rp.WithPKCE(cookieHandler))
|
||||
}
|
||||
}
|
||||
|
||||
// Set default scopes if no scopes were specified.
|
||||
if oidcScopes == "" {
|
||||
oidcScopes = "openid email profile"
|
||||
}
|
||||
|
||||
scopes := strings.Split(strings.TrimSpace(oidcScopes), " ")
|
||||
event.AuditDebug([]string{"oidc", "provider", "scopes", oidcScopes})
|
||||
|
||||
provider, err := rp.NewRelyingPartyOIDC(oidcUri.String(), oidcClient, oidcSecret, u.String(), scopes, clientOpt...)
|
||||
// Parse scopes into string slice.
|
||||
scopes := clean.Scopes(oidcScopes)
|
||||
|
||||
// Create RelyingParty provider.
|
||||
provider, err := rp.NewRelyingPartyOIDC(issuerUri.String(), oidcClient, oidcSecret, redirectUrl, scopes, clientOpt...)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("oidc: %s (issuer)", err)
|
||||
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Tracef("oidc: pkce enabled %v", provider.IsPKCE())
|
||||
if provider.IsPKCE() {
|
||||
event.AuditDebug([]string{"oidc", "provider", "pkce", "enabled"})
|
||||
} else {
|
||||
event.AuditDebug([]string{"oidc", "provider", "pkce", "disabled"})
|
||||
}
|
||||
|
||||
// Return OIDC Client with RelyingParty provider.
|
||||
return &Client{
|
||||
provider,
|
||||
debug,
|
||||
insecure,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func state() string {
|
||||
return rnd.UUID()
|
||||
}
|
||||
|
||||
// AuthCodeUrlHandler redirects a browser to the login page of the configured OIDC identity provider.
|
||||
func (c *Client) AuthCodeUrlHandler(ctx *gin.Context) {
|
||||
handle := rp.AuthURLHandler(state, c)
|
||||
handle := rp.AuthURLHandler(rnd.State, c)
|
||||
handle(ctx.Writer, ctx.Request)
|
||||
}
|
||||
|
||||
func (c *Client) CodeExchangeUserInfo(ctx *gin.Context) (userInfo oidc.UserInfo, tokens *oidc.Tokens, err error) {
|
||||
userinfoClosure := func(w http.ResponseWriter, r *http.Request, t *oidc.Tokens, state string, rp rp.RelyingParty, i oidc.UserInfo) {
|
||||
// CodeExchangeUserInfo verifies a redirect auth request and returns the user information and tokens if successful.
|
||||
func (c *Client) CodeExchangeUserInfo(ctx *gin.Context) (userInfo *oidc.UserInfo, tokens *oidc.Tokens[*oidc.IDTokenClaims], err error) {
|
||||
getInfo := func(w http.ResponseWriter, r *http.Request, t *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, i *oidc.UserInfo) {
|
||||
userInfo = i
|
||||
tokens = t
|
||||
}
|
||||
|
||||
/*
|
||||
You could also just take the access_token and id_token without calling the userinfo endpoint, e.g.:
|
||||
|
||||
tokeninfoClosure := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty) {
|
||||
log.Infof("IDTOKEN: %q\n\n" , tokens.IDToken)
|
||||
log.Infof("ACCESSTOKEN: %q\n\n" , tokens.AccessToken)
|
||||
log.Infof("REFRESHTOKEN: %q\n\n" , tokens.RefreshToken)
|
||||
*/
|
||||
|
||||
handle := rp.CodeExchangeHandler(rp.UserinfoCallback(userinfoClosure), c)
|
||||
// It would also be possible to directly get the user info from the oidc.IDTokenClaims
|
||||
// without performing a request to the userinfo endpoint of the OIDC identity provider.
|
||||
handle := rp.CodeExchangeHandler(rp.UserinfoCallback(getInfo), c)
|
||||
|
||||
handle(ctx.Writer, ctx.Request)
|
||||
|
||||
if sc := ctx.Writer.Status(); sc != 0 && sc != http.StatusOK {
|
||||
if oidcErr := ctx.Writer.Header().Get("oidc_error"); oidcErr == "" {
|
||||
return userInfo, tokens, errors.New("tailed to exchange the authentication code and retrieve the user information")
|
||||
return userInfo, tokens, errors.New("failed to exchange token for user info")
|
||||
} else {
|
||||
return userInfo, tokens, errors.New(oidcErr)
|
||||
}
|
||||
|
45
internal/auth/oidc/client_test.go
Normal file
45
internal/auth/oidc/client_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
t.Run("Prod", func(t *testing.T) {
|
||||
uri, err := url.Parse("http://dummy-oidc:9998")
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
client, err := NewClient(
|
||||
uri,
|
||||
"csg6yqvykh0780f9",
|
||||
"nd09wkee0ElsMvzLGkgWS9wJAttHwF2h",
|
||||
"openid email profile",
|
||||
"https://app.localssl.dev/",
|
||||
false,
|
||||
)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
})
|
||||
t.Run("Debug", func(t *testing.T) {
|
||||
uri, err := url.Parse("http://dummy-oidc:9998")
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
client, err := NewClient(
|
||||
uri,
|
||||
"csg6yqvykh0780f9",
|
||||
"nd09wkee0ElsMvzLGkgWS9wJAttHwF2h",
|
||||
"openid email profile",
|
||||
"https://app.localssl.dev/",
|
||||
true,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, &Client{}, client)
|
||||
})
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserInfo interface {
|
||||
GetPreferredUsername() string
|
||||
GetNickname() string
|
||||
GetName() string
|
||||
GetEmail() string
|
||||
GetClaim(key string) interface{}
|
||||
}
|
||||
|
||||
func UsernameFromUserInfo(userinfo UserInfo) (username string) {
|
||||
if len(userinfo.GetPreferredUsername()) >= 4 {
|
||||
username = userinfo.GetPreferredUsername()
|
||||
} else if len(userinfo.GetNickname()) >= 4 {
|
||||
username = userinfo.GetNickname()
|
||||
} else if len(userinfo.GetName()) >= 4 {
|
||||
username = strings.ReplaceAll(strings.ToLower(userinfo.GetName()), " ", "-")
|
||||
} else if len(userinfo.GetEmail()) >= 4 {
|
||||
username = userinfo.GetEmail()
|
||||
} else {
|
||||
log.Debug("oidc: no username found")
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
// HasRoleAdmin searches UserInfo claims for admin role.
|
||||
// Returns true if role is present or false if claim was found but no role in there.
|
||||
// Error will be returned if the role claim is not delivered at all.
|
||||
func HasRoleAdmin(userinfo UserInfo) (bool, error) {
|
||||
claim := userinfo.GetClaim(RoleClaim)
|
||||
return claimContainsProp(claim, AdminRole)
|
||||
}
|
||||
|
||||
func claimContainsProp(claim interface{}, property string) (bool, error) {
|
||||
switch t := claim.(type) {
|
||||
case nil:
|
||||
return false, errors.New("oidc: claim not found")
|
||||
case []interface{}:
|
||||
for _, value := range t {
|
||||
res, err := claimContainsProp(value, property)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if res {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
case interface{}:
|
||||
if value, ok := t.(string); ok {
|
||||
return value == property, nil
|
||||
} else {
|
||||
return false, errors.New("oidc: unexpected type")
|
||||
}
|
||||
default:
|
||||
return false, errors.New("oidc: unexpected type")
|
||||
}
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUsernameFromUserInfo(t *testing.T) {
|
||||
t.Run("PreferredUsername", func(t *testing.T) {
|
||||
u := &userinfo{PreferredUsername: "testfest"}
|
||||
assert.Equal(t, "testfest", UsernameFromUserInfo(u))
|
||||
})
|
||||
t.Run("PreferredUsername too short", func(t *testing.T) {
|
||||
u := &userinfo{PreferredUsername: "tes"}
|
||||
assert.Equal(t, "", UsernameFromUserInfo(u))
|
||||
})
|
||||
t.Run("EMail", func(t *testing.T) {
|
||||
u := &userinfo{Nickname: "tes", Email: "hello@world.com"}
|
||||
assert.Equal(t, "hello@world.com", UsernameFromUserInfo(u))
|
||||
})
|
||||
t.Run("Nickname", func(t *testing.T) {
|
||||
u := &userinfo{Nickname: "testofesto", Email: "hel"}
|
||||
assert.Equal(t, "testofesto", UsernameFromUserInfo(u))
|
||||
})
|
||||
t.Run("Name", func(t *testing.T) {
|
||||
u := &userinfo{Name: "Jane Doe", Email: "hello@world.com"}
|
||||
assert.Equal(t, "jane-doe", UsernameFromUserInfo(u))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasRoleAdmin(t *testing.T) {
|
||||
t.Run("true case", func(t *testing.T) {
|
||||
u := &userinfo{Claim: []interface{}{
|
||||
"admin",
|
||||
"photoprism_admin",
|
||||
"photoprism",
|
||||
"random",
|
||||
}}
|
||||
hasRoleAdmin, err := HasRoleAdmin(u)
|
||||
assert.True(t, hasRoleAdmin)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
t.Run("false case", func(t *testing.T) {
|
||||
u := &userinfo{Claim: []interface{}{
|
||||
"admin",
|
||||
"photoprismo_admin",
|
||||
"photoprism",
|
||||
"random",
|
||||
}}
|
||||
hasRoleAdmin, err := HasRoleAdmin(u)
|
||||
assert.False(t, hasRoleAdmin)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
t.Run("false case 2", func(t *testing.T) {
|
||||
u := &userinfo{Claim: []interface{}{}}
|
||||
hasRoleAdmin, err := HasRoleAdmin(u)
|
||||
assert.False(t, hasRoleAdmin)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
t.Run("error case", func(t *testing.T) {
|
||||
u := &userinfo{Claim: nil}
|
||||
hasRoleAdmin, err := HasRoleAdmin(u)
|
||||
assert.False(t, hasRoleAdmin)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
type userinfo struct {
|
||||
PreferredUsername string
|
||||
Nickname string
|
||||
Name string
|
||||
Email string
|
||||
Claim interface{}
|
||||
}
|
||||
|
||||
func (u *userinfo) GetPreferredUsername() string {
|
||||
return u.PreferredUsername
|
||||
}
|
||||
|
||||
func (u *userinfo) GetNickname() string {
|
||||
return u.Nickname
|
||||
}
|
||||
|
||||
func (u *userinfo) GetName() string {
|
||||
return u.Name
|
||||
}
|
||||
|
||||
func (u *userinfo) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u *userinfo) GetClaim(key string) interface{} {
|
||||
return u.Claim
|
||||
}
|
@@ -3,6 +3,8 @@ package oidc
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
)
|
||||
|
||||
// HttpClient represents a client that makes HTTP requests.
|
||||
@@ -16,11 +18,11 @@ func HttpClient(debug bool) *http.Client {
|
||||
if debug {
|
||||
return &http.Client{
|
||||
Transport: LoggingRoundTripper{http.DefaultTransport},
|
||||
Timeout: time.Second * 20,
|
||||
Timeout: time.Second * 30,
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// LoggingRoundTripper specifies the http.RoundTripper interface.
|
||||
@@ -28,15 +30,16 @@ type LoggingRoundTripper struct {
|
||||
proxy http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip logs the request method, URL and error, if any.
|
||||
func (lrt LoggingRoundTripper) RoundTrip(req *http.Request) (res *http.Response, err error) {
|
||||
log.Tracef("oidc: %s %s", req.Method, req.URL.String())
|
||||
|
||||
// Send request.
|
||||
// Perform HTTP request.
|
||||
res, err = lrt.proxy.RoundTrip(req)
|
||||
|
||||
// Log error, if any.
|
||||
// Log the request method, URL and error, if any.
|
||||
if err != nil {
|
||||
log.Debugf("oidc: request to %s has failed (%s)", req.URL.String(), err)
|
||||
event.AuditErr([]string{"oidc", "provider", "request", "%s %s", "%s"}, req.Method, req.URL.String(), err)
|
||||
} else {
|
||||
event.AuditDebug([]string{"oidc", "provider", "request", "%s %s", "%s"}, req.Method, req.URL.String(), res.Status)
|
||||
}
|
||||
|
||||
return res, err
|
||||
|
@@ -20,7 +20,7 @@ func TestHttpClient(t *testing.T) {
|
||||
assert.IsType(t, LoggingRoundTripper{}, client.Transport)
|
||||
})
|
||||
t.Run("GetRequest", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "https://www.photoprism.app/", nil)
|
||||
req, err := http.NewRequest("GET", "https://accounts.google.com/.well-known/openid-configuration", nil)
|
||||
assert.Nil(t, err)
|
||||
rt := LoggingRoundTripper{http.DefaultTransport}
|
||||
_, err = rt.RoundTrip(req)
|
||||
|
26
internal/auth/oidc/redirect_url.go
Normal file
26
internal/auth/oidc/redirect_url.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
// RedirectURL returns the redirect URL for authentication via OIDC based on the specified site URL.
|
||||
func RedirectURL(siteUrl string) (string, error) {
|
||||
if siteUrl == "" {
|
||||
return "", errors.New("site url required")
|
||||
}
|
||||
|
||||
u, err := url.Parse(siteUrl)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, config.OidcRedirectUri)
|
||||
|
||||
return u.String(), nil
|
||||
}
|
56
internal/auth/oidc/username.go
Normal file
56
internal/auth/oidc/username.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// Username returns the preferred username based on the userinfo and the preferred username OIDC claim.
|
||||
func Username(userInfo *oidc.UserInfo, preferredClaim string) (userName string) {
|
||||
switch preferredClaim {
|
||||
case authn.ClaimName:
|
||||
if name := clean.Handle(userInfo.Name); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.PreferredUsername); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Nickname); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.Email); userInfo.EmailVerified && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
case authn.ClaimNickname:
|
||||
if name := clean.Handle(userInfo.Nickname); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.PreferredUsername); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Name); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.Email); userInfo.EmailVerified && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
case authn.ClaimEmail:
|
||||
if name := clean.Email(userInfo.Email); userInfo.EmailVerified && len(name) > 4 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.PreferredUsername); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Name); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Nickname); len(name) > 0 {
|
||||
userName = name
|
||||
}
|
||||
default:
|
||||
if name := clean.Handle(userInfo.PreferredUsername); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Name); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.Nickname); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.Email); userInfo.EmailVerified && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
}
|
||||
|
||||
return userName
|
||||
}
|
69
internal/auth/oidc/username_test.go
Normal file
69
internal/auth/oidc/username_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
)
|
||||
|
||||
func TestUsername(t *testing.T) {
|
||||
t.Run("ClaimPreferredUsername", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
|
||||
info.PreferredUsername = "Jane Doe"
|
||||
result := Username(info, authn.ClaimPreferredUsername)
|
||||
assert.Equal(t, "jane.doe", result)
|
||||
})
|
||||
t.Run("ClaimPreferredUsernameMissing", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.FamilyName = "Doe"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
result := Username(info, authn.ClaimPreferredUsername)
|
||||
assert.Equal(t, "jane.doe", result)
|
||||
})
|
||||
t.Run("ClaimName", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Nickname = "Jens Mander"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "abcd123"
|
||||
result := Username(info, authn.ClaimName)
|
||||
assert.Equal(t, "jane.doe", result)
|
||||
})
|
||||
t.Run("ClaimNickname", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Nickname = "Jens Mander"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "abcd123"
|
||||
result := Username(info, authn.ClaimNickname)
|
||||
assert.Equal(t, "jens.mander", result)
|
||||
})
|
||||
t.Run("ClaimEmail", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "abcd123"
|
||||
result := Username(info, authn.ClaimEmail)
|
||||
assert.Equal(t, "jane@doe.com", result)
|
||||
})
|
||||
}
|
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/ulule/deepcopier"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
@@ -105,64 +105,17 @@ func NewUser() (m *User) {
|
||||
}
|
||||
|
||||
// OidcUser creates a new OIDC user entity.
|
||||
func OidcUser(userInfo oidc.UserInfo, usernameClaim string) User {
|
||||
var userName, userEmail string
|
||||
func OidcUser(userInfo *oidc.UserInfo, userName string) User {
|
||||
authId := clean.Auth(userInfo.Subject)
|
||||
|
||||
switch usernameClaim {
|
||||
case authn.ClaimName:
|
||||
if name := clean.Handle(userInfo.GetName()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetNickname()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
case authn.ClaimNickname:
|
||||
if name := clean.Handle(userInfo.GetNickname()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
case authn.ClaimEmail:
|
||||
if name := clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetNickname()); len(name) > 0 {
|
||||
userName = name
|
||||
}
|
||||
default:
|
||||
if name := clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Handle(userInfo.GetNickname()); len(name) > 0 {
|
||||
userName = name
|
||||
} else if name = clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
|
||||
userName = name
|
||||
}
|
||||
}
|
||||
|
||||
userEmail = clean.Email(userInfo.GetEmail())
|
||||
|
||||
authId := clean.Auth(userInfo.GetSubject())
|
||||
|
||||
if userName == "" || authId == "" {
|
||||
if authId == "" {
|
||||
return User{}
|
||||
}
|
||||
|
||||
return User{
|
||||
DisplayName: userInfo.GetName(),
|
||||
UserName: userName,
|
||||
UserEmail: userEmail,
|
||||
DisplayName: userInfo.Name,
|
||||
UserEmail: clean.Email(userInfo.Email),
|
||||
AuthID: authId,
|
||||
AuthProvider: authn.ProviderOIDC.String(),
|
||||
}
|
||||
|
@@ -4,9 +4,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
@@ -22,77 +21,59 @@ func TestNewUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOidcUser(t *testing.T) {
|
||||
t.Run("ClaimPreferredUsername", func(t *testing.T) {
|
||||
info := oidc.NewUserInfo()
|
||||
info.SetName("Jane Doe")
|
||||
info.SetGivenName("Jane")
|
||||
info.SetFamilyName("Doe")
|
||||
info.SetEmail("jane@doe.com", true)
|
||||
info.SetSubject("abcd123")
|
||||
info.SetPreferredUsername("Jane Doe")
|
||||
m := OidcUser(info, authn.ClaimPreferredUsername)
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
|
||||
info.PreferredUsername = "Jane Doe"
|
||||
|
||||
m := OidcUser(info, "jane.doe")
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "abcd123", m.AuthID)
|
||||
assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "jane.doe", m.UserName)
|
||||
assert.Equal(t, "Jane Doe", m.DisplayName)
|
||||
})
|
||||
t.Run("ClaimName", func(t *testing.T) {
|
||||
info := oidc.NewUserInfo()
|
||||
info.SetName("Jane Doe")
|
||||
info.SetGivenName("Jane")
|
||||
info.SetFamilyName("Doe")
|
||||
info.SetNickname("Jens Mander")
|
||||
info.SetEmail("jane@doe.com", true)
|
||||
info.SetSubject("abcd123")
|
||||
m := OidcUser(info, authn.ClaimName)
|
||||
t.Run("NoUsername", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
|
||||
info.PreferredUsername = "Jane Doe"
|
||||
|
||||
m := OidcUser(info, "")
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "abcd123", m.AuthID)
|
||||
assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "jane.doe", m.UserName)
|
||||
assert.Equal(t, "", m.UserName)
|
||||
assert.Equal(t, "Jane Doe", m.DisplayName)
|
||||
})
|
||||
t.Run("ClaimNickname", func(t *testing.T) {
|
||||
info := oidc.NewUserInfo()
|
||||
info.SetName("Jane Doe")
|
||||
info.SetGivenName("Jane")
|
||||
info.SetFamilyName("Doe")
|
||||
info.SetNickname("Jens Mander")
|
||||
info.SetEmail("jane@doe.com", true)
|
||||
info.SetSubject("abcd123")
|
||||
m := OidcUser(info, authn.ClaimNickname)
|
||||
t.Run("NoSubject", func(t *testing.T) {
|
||||
info := &oidc.UserInfo{}
|
||||
info.Name = "Jane Doe"
|
||||
info.GivenName = "Jane"
|
||||
info.FamilyName = "Doe"
|
||||
info.Nickname = "Jens Mander"
|
||||
info.Email = "jane@doe.com"
|
||||
info.EmailVerified = true
|
||||
info.Subject = ""
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "abcd123", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "jens.mander", m.UserName)
|
||||
assert.Equal(t, "Jane Doe", m.DisplayName)
|
||||
})
|
||||
t.Run("ClaimEmail", func(t *testing.T) {
|
||||
info := oidc.NewUserInfo()
|
||||
info.SetName("Jane Doe")
|
||||
info.SetGivenName("Jane")
|
||||
info.SetFamilyName("Doe")
|
||||
info.SetEmail("jane@doe.com", true)
|
||||
info.SetSubject("abcd123")
|
||||
m := OidcUser(info, authn.ClaimEmail)
|
||||
m := OidcUser(info, "jane.doe")
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "abcd123", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "jane@doe.com", m.UserName)
|
||||
assert.Equal(t, "Jane Doe", m.DisplayName)
|
||||
})
|
||||
t.Run("EmptyAuthId", func(t *testing.T) {
|
||||
info := oidc.NewUserInfo()
|
||||
info.SetName("Jane")
|
||||
info.SetFamilyName("Doe")
|
||||
info.SetEmail("jane@doe.com", true)
|
||||
m := OidcUser(info, authn.ClaimPreferredUsername)
|
||||
|
||||
assert.Empty(t, m)
|
||||
assert.Equal(t, "", m.AuthProvider)
|
||||
assert.Equal(t, "", m.AuthID)
|
||||
assert.Equal(t, "", m.UserEmail)
|
||||
assert.Equal(t, "", m.UserName)
|
||||
assert.Equal(t, "", m.DisplayName)
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -23,3 +23,7 @@ Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package get
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/event"
|
||||
|
||||
var log = event.Log
|
||||
|
@@ -15,7 +15,7 @@ func initOidc() {
|
||||
Config().OIDCSecret(),
|
||||
Config().OIDCScopes(),
|
||||
Config().SiteUrl(),
|
||||
Config().Debug(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -64,7 +64,7 @@ var services struct {
|
||||
|
||||
func SetConfig(c *config.Config) {
|
||||
if c == nil {
|
||||
panic("config is nil")
|
||||
log.Panic("panic: argument is nil in get.SetConfig(c *config.Config)")
|
||||
}
|
||||
|
||||
conf = c
|
||||
@@ -74,7 +74,7 @@ func SetConfig(c *config.Config) {
|
||||
|
||||
func Config() *config.Config {
|
||||
if conf == nil {
|
||||
panic("config is nil")
|
||||
log.Panic("panic: conf is nil in get.Config()")
|
||||
}
|
||||
|
||||
return conf
|
||||
|
@@ -2,6 +2,7 @@ package authn
|
||||
|
||||
// Generic status messages for authentication and authorization:
|
||||
const (
|
||||
Failed = "failed"
|
||||
Denied = "denied"
|
||||
Granted = "granted"
|
||||
Created = "created"
|
||||
|
@@ -14,3 +14,12 @@ func Scope(s string) string {
|
||||
|
||||
return list.ParseAttr(strings.ToLower(s)).String()
|
||||
}
|
||||
|
||||
// Scopes sanitizes authentication scope identifiers and returns them as string slice.
|
||||
func Scopes(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return list.ParseAttr(strings.ToLower(s)).Strings()
|
||||
}
|
||||
|
@@ -20,3 +20,18 @@ func TestScope(t *testing.T) {
|
||||
assert.Equal(t, "*", q)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScopes(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
q := Scopes("")
|
||||
assert.Equal(t, []string{}, q)
|
||||
})
|
||||
t.Run("Sanitized", func(t *testing.T) {
|
||||
q := Scopes(" foo:BAR webdav openid metrics !")
|
||||
assert.Equal(t, []string{"foo:bar", "metrics", "openid", "webdav"}, q)
|
||||
})
|
||||
t.Run("All", func(t *testing.T) {
|
||||
q := Scopes("*")
|
||||
assert.Equal(t, []string{"*"}, q)
|
||||
})
|
||||
}
|
||||
|
@@ -26,6 +26,11 @@ func ParseAttr(s string) Attr {
|
||||
|
||||
// String returns the attributes as string.
|
||||
func (list Attr) String() string {
|
||||
return strings.Join(list.Strings(), " ")
|
||||
}
|
||||
|
||||
// Strings returns the attributes as string slice.
|
||||
func (list Attr) Strings() []string {
|
||||
result := make([]string, 0, len(list))
|
||||
|
||||
list.Sort()
|
||||
@@ -55,7 +60,7 @@ func (list Attr) String() string {
|
||||
i++
|
||||
}
|
||||
|
||||
return strings.Join(result, " ")
|
||||
return result
|
||||
}
|
||||
|
||||
// Sort sorts the attributes by key.
|
||||
|
@@ -10,3 +10,8 @@ import (
|
||||
func UUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
// State is an alias for UUID for use in the context of OpenID Connect (OIDC).
|
||||
func State() string {
|
||||
return UUID()
|
||||
}
|
||||
|
Reference in New Issue
Block a user