mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 08:44:04 +01:00
OIDC: Improve auth api logs and user verification #782
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -29,7 +29,6 @@ func OIDCLogin(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// Get client IP address for logs and rate limiting checks.
|
// Get client IP address for logs and rate limiting checks.
|
||||||
clientIp := ClientIP(c)
|
clientIp := ClientIP(c)
|
||||||
actor := "unknown user"
|
|
||||||
action := "sign in"
|
action := "sign in"
|
||||||
|
|
||||||
// Get global config.
|
// Get global config.
|
||||||
@@ -37,11 +36,11 @@ func OIDCLogin(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// Abort in public mode and if OIDC is disabled.
|
// Abort in public mode and if OIDC is disabled.
|
||||||
if get.Config().Public() {
|
if get.Config().Public() {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrDisabledInPublicMode.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrDisabledInPublicMode.Error()})
|
||||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||||
return
|
return
|
||||||
} else if !conf.OIDCEnabled() {
|
} else if !conf.OIDCEnabled() {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAuthenticationDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||||
Abort(c, http.StatusMethodNotAllowed, i18n.ErrUnsupported)
|
Abort(c, http.StatusMethodNotAllowed, i18n.ErrUnsupported)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,7 +59,7 @@ func OIDCLogin(router *gin.RouterGroup) {
|
|||||||
provider := get.OIDC()
|
provider := get.OIDC()
|
||||||
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAuthenticationDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidProvider.Error()})
|
||||||
Abort(c, http.StatusInternalServerError, i18n.ErrConnectionFailed)
|
Abort(c, http.StatusInternalServerError, i18n.ErrConnectionFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,12 +27,9 @@ import (
|
|||||||
// GET /api/v1/oidc/redirect
|
// GET /api/v1/oidc/redirect
|
||||||
func OIDCRedirect(router *gin.RouterGroup) {
|
func OIDCRedirect(router *gin.RouterGroup) {
|
||||||
router.GET("/oidc/redirect", func(c *gin.Context) {
|
router.GET("/oidc/redirect", func(c *gin.Context) {
|
||||||
// Get global config.
|
|
||||||
conf := get.Config()
|
|
||||||
|
|
||||||
// Prevent CDNs from caching this endpoint.
|
// Prevent CDNs from caching this endpoint.
|
||||||
if header.IsCdn(c.Request) {
|
if header.IsCdn(c.Request) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
AbortNotFound(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,16 +38,20 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// Get client IP address for logs and rate limiting checks.
|
// Get client IP address for logs and rate limiting checks.
|
||||||
clientIp := ClientIP(c)
|
clientIp := ClientIP(c)
|
||||||
actor := "unknown user"
|
userAgent := UserAgent(c)
|
||||||
|
userName := "unknown user"
|
||||||
action := "sign in"
|
action := "sign in"
|
||||||
|
|
||||||
|
// Get global config.
|
||||||
|
conf := get.Config()
|
||||||
|
|
||||||
// Abort in public mode and if OIDC is disabled.
|
// Abort in public mode and if OIDC is disabled.
|
||||||
if get.Config().Public() {
|
if get.Config().Public() {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrDisabledInPublicMode.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrDisabledInPublicMode.Error()})
|
||||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||||
return
|
return
|
||||||
} else if !conf.OIDCEnabled() {
|
} else if !conf.OIDCEnabled() {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAuthenticationDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,7 +68,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// Check if the required request parameters are present.
|
// Check if the required request parameters are present.
|
||||||
if c.Query("state") == "" || c.Query("code") == "" {
|
if c.Query("state") == "" || c.Query("code") == "" {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAuthCodeRequired.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthCodeRequired.Error()})
|
||||||
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
provider := get.OIDC()
|
provider := get.OIDC()
|
||||||
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAuthenticationDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
userInfo, tokens, claimErr := provider.CodeExchangeUserInfo(c)
|
userInfo, tokens, claimErr := provider.CodeExchangeUserInfo(c)
|
||||||
|
|
||||||
if claimErr != nil {
|
if claimErr != nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, claimErr.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, claimErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,24 +93,47 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
var user *entity.User
|
var user *entity.User
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
userEmail := clean.Email(userInfo.GetEmail())
|
||||||
|
|
||||||
|
// 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()})
|
||||||
|
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.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.
|
// Find existing user record and update it, if necessary.
|
||||||
if oidcUser := entity.OidcUser(userInfo, conf.OIDCUsername()); oidcUser.UserName == "" || authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
|
if oidcUser := entity.OidcUser(userInfo, conf.OIDCUsername()); oidcUser.UserName == "" || authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrInvalidUsername.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidUsername.Error()})
|
||||||
|
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrInvalidUsername.Error())
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
} else if user = entity.FindUser(oidcUser); user != nil {
|
} else if user = entity.FindUser(oidcUser); user != nil {
|
||||||
// Check if username and subject UID match.
|
// Check if username and subject UID match.
|
||||||
if user.Username() == "" || oidcUser.UserName == "" || user.Username() != oidcUser.UserName {
|
if user.Username() == "" || oidcUser.UserName == "" || user.Username() != oidcUser.UserName {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrInvalidUsername.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidUsername.Error()})
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrInvalidUsername.Error())
|
||||||
return
|
|
||||||
} else if user.AuthID == "" || oidcUser.AuthID == "" || user.AuthID != oidcUser.AuthID {
|
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrInvalidAuthID.Error()})
|
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
actor = user.Username()
|
userName = user.Username()
|
||||||
|
|
||||||
|
if user.AuthID == "" || oidcUser.AuthID == "" || user.AuthID != oidcUser.AuthID {
|
||||||
|
event.AuditErr([]string{clientIp, "oidc", action, 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.
|
// Update user profile information.
|
||||||
details := user.Details()
|
details := user.Details()
|
||||||
@@ -163,9 +189,10 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
user.VerifiedAt = entity.TimeStamp()
|
user.VerifiedAt = entity.TimeStamp()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user account.
|
// Update existing user account.
|
||||||
if err = user.Save(); err != nil {
|
if err = user.Save(); err != nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAccountUpdateFailed.Error(), err.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, 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)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,14 +201,14 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
if avatarUrl := userInfo.GetPicture(); avatarUrl == "" || user.HasAvatar() {
|
if avatarUrl := userInfo.GetPicture(); avatarUrl == "" || user.HasAvatar() {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC); err != nil {
|
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC); err != nil {
|
||||||
event.AuditWarn([]string{clientIp, "oidc", actor, action, "failed to set avatar image", err.Error()})
|
event.AuditWarn([]string{clientIp, "oidc", action, userName, "failed to set avatar image", err.Error()})
|
||||||
}
|
}
|
||||||
} else if conf.OIDCRegister() {
|
} else if conf.OIDCRegister() {
|
||||||
action = "sign up"
|
action = "sign up"
|
||||||
|
|
||||||
// Create new user record.
|
// Create new user record.
|
||||||
user = &oidcUser
|
user = &oidcUser
|
||||||
actor = user.Username()
|
userName = user.Username()
|
||||||
|
|
||||||
// Set user profile information.
|
// Set user profile information.
|
||||||
user.SetDisplayName(userInfo.GetName(), entity.SrcOIDC)
|
user.SetDisplayName(userInfo.GetName(), entity.SrcOIDC)
|
||||||
@@ -227,28 +254,31 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
user.CanLogin = true
|
user.CanLogin = true
|
||||||
user.WebDAV = conf.OIDCWebDAV()
|
user.WebDAV = conf.OIDCWebDAV()
|
||||||
|
|
||||||
// Create user account.
|
// Create new user account.
|
||||||
if err = user.Create(); err != nil {
|
if err = user.Create(); err != nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAccountCreateFailed.Error(), err.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, 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)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set user avatar image.
|
// Set user avatar image.
|
||||||
if avatarUrl := userInfo.GetPicture(); avatarUrl == "" {
|
if avatarUrl := userInfo.GetPicture(); avatarUrl == "" {
|
||||||
event.AuditDebug([]string{clientIp, "oidc", actor, action, "no avatar image provided"})
|
event.AuditDebug([]string{clientIp, "oidc", action, userName, "no avatar image provided"})
|
||||||
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC); err != nil {
|
} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC); err != nil {
|
||||||
event.AuditWarn([]string{clientIp, "oidc", actor, action, "failed to set avatar image", err.Error()})
|
event.AuditWarn([]string{clientIp, "oidc", action, userName, "failed to set avatar image", err.Error()})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrRegistrationDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, 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)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login allowed?
|
// Login allowed?
|
||||||
if !user.CanLogIn() {
|
if !user.CanLogIn() {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, authn.ErrAccountDisabled.Error()})
|
event.AuditErr([]string{clientIp, "oidc", action, 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)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,10 +301,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
|
|
||||||
// Save session after successful authentication.
|
// Save session after successful authentication.
|
||||||
if sess, err = get.Session().Save(sess); err != nil {
|
if sess, err = get.Session().Save(sess); err != nil {
|
||||||
event.AuditErr([]string{clientIp, "oidc", actor, action, "%s"}, err)
|
event.AuditErr([]string{clientIp, "oidc", action, userName, "%s"}, err)
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||||
return
|
return
|
||||||
} else if sess == nil {
|
} else if sess == nil {
|
||||||
|
event.AuditErr([]string{clientIp, "oidc", action, userName, "session is nil"})
|
||||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
|
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,7 +317,8 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
|||||||
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
|
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
|
||||||
|
|
||||||
// Log success.
|
// Log success.
|
||||||
event.AuditInfo([]string{clientIp, "oidc", actor, action, authn.Succeeded})
|
event.AuditInfo([]string{clientIp, "oidc", action, userName, authn.Succeeded})
|
||||||
|
event.LoginInfo(clientIp, "oidc", userName, userAgent)
|
||||||
|
|
||||||
// Update login timestamp.
|
// Update login timestamp.
|
||||||
user.UpdateLoginTime()
|
user.UpdateLoginTime()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type Client struct {
|
|||||||
debug bool
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(iss *url.URL, clientId, clientSecret, customScopes, siteUrl string, debug bool) (result *Client, err error) {
|
func NewClient(oidcUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl string, debug bool) (result *Client, err error) {
|
||||||
u, err := url.Parse(siteUrl)
|
u, err := url.Parse(siteUrl)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,7 +68,7 @@ func NewClient(iss *url.URL, clientId, clientSecret, customScopes, siteUrl strin
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
discover, err := client.Discover(iss.String(), httpClient)
|
discover, err := client.Discover(oidcUri.String(), httpClient)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("oidc: %q (discover)", err)
|
log.Debugf("oidc: %q (discover)", err)
|
||||||
@@ -81,9 +81,13 @@ func NewClient(iss *url.URL, clientId, clientSecret, customScopes, siteUrl strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scopes := strings.Split(strings.TrimSpace("openid email profile "+customScopes), " ")
|
if oidcScopes == "" {
|
||||||
|
oidcScopes = "openid email profile"
|
||||||
|
}
|
||||||
|
|
||||||
provider, err := rp.NewRelyingPartyOIDC(iss.String(), clientId, clientSecret, u.String(), scopes, clientOpt...)
|
scopes := strings.Split(strings.TrimSpace(oidcScopes), " ")
|
||||||
|
|
||||||
|
provider, err := rp.NewRelyingPartyOIDC(oidcUri.String(), oidcClient, oidcSecret, u.String(), scopes, clientOpt...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("oidc: %s (issuer)", err)
|
log.Debugf("oidc: %s (issuer)", err)
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ func (c *Config) OIDCUsername() string {
|
|||||||
return authn.ClaimUsername
|
return authn.ClaimUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDCDomain returns the email domain name for restricted single sign-on via OIDC.
|
||||||
|
func (c *Config) OIDCDomain() string {
|
||||||
|
return clean.Domain(c.options.OIDCDomain)
|
||||||
|
}
|
||||||
|
|
||||||
// OIDCRole returns the default user role when signing up via OIDC.
|
// OIDCRole returns the default user role when signing up via OIDC.
|
||||||
func (c *Config) OIDCRole() acl.Role {
|
func (c *Config) OIDCRole() acl.Role {
|
||||||
if c.options.OIDCRole == "" {
|
if c.options.OIDCRole == "" {
|
||||||
@@ -153,10 +158,17 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) {
|
|||||||
{"oidc-redirect", fmt.Sprintf("%t", c.OIDCRedirect())},
|
{"oidc-redirect", fmt.Sprintf("%t", c.OIDCRedirect())},
|
||||||
{"oidc-register", fmt.Sprintf("%t", c.OIDCRegister())},
|
{"oidc-register", fmt.Sprintf("%t", c.OIDCRegister())},
|
||||||
{"oidc-username", c.OIDCUsername()},
|
{"oidc-username", c.OIDCUsername()},
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain := c.OIDCDomain(); domain != "" {
|
||||||
|
rows = append(rows, []string{"oidc-domain", domain})
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, [][]string{
|
||||||
{"oidc-role", c.OIDCRole().String()},
|
{"oidc-role", c.OIDCRole().String()},
|
||||||
{"oidc-webdav", fmt.Sprintf("%t", c.OIDCWebDAV())},
|
{"oidc-webdav", fmt.Sprintf("%t", c.OIDCWebDAV())},
|
||||||
{"disable-oidc", fmt.Sprintf("%t", c.DisableOIDC())},
|
{"disable-oidc", fmt.Sprintf("%t", c.DisableOIDC())},
|
||||||
}
|
}...)
|
||||||
|
|
||||||
return rows, cols
|
return rows, cols
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ func TestConfig_OIDCUsername(t *testing.T) {
|
|||||||
assert.Equal(t, authn.ClaimUsername, c.OIDCUsername())
|
assert.Equal(t, authn.ClaimUsername, c.OIDCUsername())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_OIDCDomain(t *testing.T) {
|
||||||
|
c := NewConfig(CliTestContext())
|
||||||
|
|
||||||
|
assert.Equal(t, "", c.OIDCDomain())
|
||||||
|
|
||||||
|
c.options.OIDCDomain = "example.com"
|
||||||
|
|
||||||
|
assert.Equal(t, "example.com", c.OIDCDomain())
|
||||||
|
|
||||||
|
c.options.OIDCDomain = "foo"
|
||||||
|
|
||||||
|
assert.Equal(t, "", c.OIDCDomain())
|
||||||
|
|
||||||
|
c.options.OIDCDomain = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "", c.OIDCDomain())
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig_OIDCRegister(t *testing.T) {
|
func TestConfig_OIDCRegister(t *testing.T) {
|
||||||
c := NewConfig(CliTestContext())
|
c := NewConfig(CliTestContext())
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/config/ttl"
|
|
||||||
|
|
||||||
"github.com/klauspost/cpuid/v2"
|
"github.com/klauspost/cpuid/v2"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/ai/face"
|
"github.com/photoprism/photoprism/internal/ai/face"
|
||||||
|
"github.com/photoprism/photoprism/internal/config/ttl"
|
||||||
"github.com/photoprism/photoprism/internal/entity"
|
"github.com/photoprism/photoprism/internal/entity"
|
||||||
"github.com/photoprism/photoprism/internal/ffmpeg"
|
"github.com/photoprism/photoprism/internal/ffmpeg"
|
||||||
"github.com/photoprism/photoprism/internal/thumb"
|
"github.com/photoprism/photoprism/internal/thumb"
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ type Options struct {
|
|||||||
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
|
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
|
||||||
PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
|
PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
|
||||||
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"`
|
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"`
|
||||||
RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri"`
|
RegisterUri string `yaml:"-" json:"-" flag:"register-uri"`
|
||||||
LoginUri string `yaml:"LoginUri" json:"-" flag:"login-uri"`
|
LoginUri string `yaml:"-" json:"-" flag:"login-uri"`
|
||||||
OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"`
|
OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"`
|
||||||
OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"`
|
OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"`
|
||||||
OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"`
|
OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"`
|
||||||
@@ -40,7 +40,8 @@ type Options struct {
|
|||||||
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
|
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
|
||||||
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
|
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
|
||||||
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
|
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
|
||||||
OIDCRole string `yaml:"OIDCRole" json:"-" flag:"oidc-role"`
|
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain"`
|
||||||
|
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role"`
|
||||||
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
|
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
|
||||||
DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"`
|
DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"`
|
||||||
SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"`
|
SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"`
|
||||||
|
|||||||
@@ -31,14 +31,16 @@ var (
|
|||||||
|
|
||||||
// OIDC and OAuth2-related error messages:
|
// OIDC and OAuth2-related error messages:
|
||||||
var (
|
var (
|
||||||
ErrInvalidGrantType = errors.New("invalid grant type")
|
ErrInvalidProvider = errors.New("invalid provider")
|
||||||
ErrInvalidClientID = errors.New("invalid client id")
|
ErrInvalidGrantType = errors.New("invalid grant type")
|
||||||
ErrInvalidAuthID = errors.New("invalid auth id")
|
ErrInvalidClientID = errors.New("invalid client id")
|
||||||
ErrAuthCodeRequired = errors.New("auth code required")
|
ErrInvalidAuthID = errors.New("invalid auth id")
|
||||||
ErrClientIDRequired = errors.New("client id required")
|
ErrAuthCodeRequired = errors.New("auth code required")
|
||||||
ErrInvalidClientSecret = errors.New("invalid client secret")
|
ErrClientIDRequired = errors.New("client id required")
|
||||||
ErrClientSecretRequired = errors.New("client secret required")
|
ErrInvalidClientSecret = errors.New("invalid client secret")
|
||||||
ErrRegistrationDisabled = errors.New("registration disabled")
|
ErrClientSecretRequired = errors.New("client secret required")
|
||||||
|
ErrVerifiedEmailRequired = errors.New("verified email required")
|
||||||
|
ErrRegistrationDisabled = errors.New("registration disabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
// User-related error messages:
|
// User-related error messages:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var EmailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
var EmailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||||
|
var DomainRegexp = regexp.MustCompile("^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$")
|
||||||
|
|
||||||
// Auth returns the sanitized authentication identifier trimmed to a maximum length of 255 characters.
|
// Auth returns the sanitized authentication identifier trimmed to a maximum length of 255 characters.
|
||||||
func Auth(s string) string {
|
func Auth(s string) string {
|
||||||
@@ -115,6 +116,22 @@ func Email(s string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain returns the normalized domain name with trimmed whitespace and in lowercase.
|
||||||
|
func Domain(s string) string {
|
||||||
|
// Empty or too long?
|
||||||
|
if s == "" || reject(s, txt.ClipName) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
|
||||||
|
if DomainRegexp.MatchString(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Role returns the sanitized role with trimmed whitespace and in lowercase.
|
// Role returns the sanitized role with trimmed whitespace and in lowercase.
|
||||||
func Role(s string) string {
|
func Role(s string) string {
|
||||||
// Remove unwanted characters.
|
// Remove unwanted characters.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package clean
|
package clean
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -107,6 +108,37 @@ func TestEmail(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDomain(t *testing.T) {
|
||||||
|
t.Run("Valid", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "photoprism.app", Domain("photoprism.app"))
|
||||||
|
})
|
||||||
|
t.Run("Whitespace", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "photoprism.app", Domain(" photoprism.app "))
|
||||||
|
})
|
||||||
|
t.Run("Hostname", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "foo.example.com", Domain(" FOO.example.Com "))
|
||||||
|
})
|
||||||
|
t.Run("Example", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Domain("example"))
|
||||||
|
})
|
||||||
|
t.Run("Invalid", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Domain(" hello-photoprism "))
|
||||||
|
})
|
||||||
|
t.Run("Empty", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Domain(""))
|
||||||
|
})
|
||||||
|
t.Run("Match", func(t *testing.T) {
|
||||||
|
email := "john.doe@example.com"
|
||||||
|
domain := Domain("example.com")
|
||||||
|
|
||||||
|
_, emailDomain, _ := strings.Cut(Email(email), "@")
|
||||||
|
|
||||||
|
assert.True(t, strings.HasSuffix("."+emailDomain, "."+domain))
|
||||||
|
assert.False(t, strings.HasSuffix(".my-"+emailDomain, "."+domain))
|
||||||
|
assert.True(t, strings.HasSuffix("my-"+emailDomain, domain))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRole(t *testing.T) {
|
func TestRole(t *testing.T) {
|
||||||
t.Run("Admin ", func(t *testing.T) {
|
t.Run("Admin ", func(t *testing.T) {
|
||||||
assert.Equal(t, "admin", Role("Admin "))
|
assert.Equal(t, "admin", Role("Admin "))
|
||||||
|
|||||||
Reference in New Issue
Block a user