123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824 |
- // Copyright (C) 2019-2023 Nicola Murino
- //
- // This program is free software: you can redistribute it and/or modify
- // it under the terms of the GNU Affero General Public License as published
- // by the Free Software Foundation, version 3.
- //
- // This program is distributed in the hope that it will be useful,
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- // GNU Affero General Public License for more details.
- //
- // You should have received a copy of the GNU Affero General Public License
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
- package httpd
- import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/coreos/go-oidc/v3/oidc"
- "github.com/rs/xid"
- "golang.org/x/oauth2"
- "github.com/drakkan/sftpgo/v2/internal/common"
- "github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/httpclient"
- "github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
- )
- const (
- oidcCookieKey = "oidc"
- adminRoleFieldValue = "admin"
- authStateValidity = 1 * 60 * 1000 // 1 minute
- tokenUpdateInterval = 3 * 60 * 1000 // 3 minutes
- tokenDeleteInterval = 2 * 3600 * 1000 // 2 hours
- )
- var (
- oidcTokenKey = &contextKey{"OIDC token key"}
- oidcGeneratedToken = &contextKey{"OIDC generated token"}
- )
- // OAuth2Config defines an interface for OAuth2 methods, so we can mock them
- type OAuth2Config interface {
- AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
- Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
- TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource
- }
- // OIDCTokenVerifier defines an interface for OpenID token verifier, so we can mock them
- type OIDCTokenVerifier interface {
- Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error)
- }
- // OIDC defines the OpenID Connect configuration
- type OIDC struct {
- // ClientID is the application's ID
- ClientID string `json:"client_id" mapstructure:"client_id"`
- // ClientSecret is the application's secret
- ClientSecret string `json:"client_secret" mapstructure:"client_secret"`
- ClientSecretFile string `json:"client_secret_file" mapstructure:"client_secret_file"`
- // ConfigURL is the identifier for the service.
- // SFTPGo will try to retrieve the provider configuration on startup and then
- // will refuse to start if it fails to connect to the specified URL
- ConfigURL string `json:"config_url" mapstructure:"config_url"`
- // RedirectBaseURL is the base URL to redirect to after OpenID authentication.
- // The suffix "/web/oidc/redirect" will be added to this base URL, adding also the
- // "web_root" if configured
- RedirectBaseURL string `json:"redirect_base_url" mapstructure:"redirect_base_url"`
- // ID token claims field to map to the SFTPGo username
- UsernameField string `json:"username_field" mapstructure:"username_field"`
- // Optional ID token claims field to map to a SFTPGo role.
- // If the defined ID token claims field is set to "admin" the authenticated user
- // is mapped to an SFTPGo admin.
- // You don't need to specify this field if you want to use OpenID only for the
- // Web Client UI
- RoleField string `json:"role_field" mapstructure:"role_field"`
- // If set, the `RoleField` is ignored and the SFTPGo role is assumed based on
- // the login link used
- ImplicitRoles bool `json:"implicit_roles" mapstructure:"implicit_roles"`
- // Scopes required by the OAuth provider to retrieve information about the authenticated user.
- // The "openid" scope is required.
- // Refer to your OAuth provider documentation for more information about this
- Scopes []string `json:"scopes" mapstructure:"scopes"`
- // Custom token claims fields to pass to the pre-login hook
- CustomFields []string `json:"custom_fields" mapstructure:"custom_fields"`
- // InsecureSkipSignatureCheck causes SFTPGo to skip JWT signature validation.
- // It's intended for special cases where providers, such as Azure, use the "none"
- // algorithm. Skipping the signature validation can cause security issues
- InsecureSkipSignatureCheck bool `json:"insecure_skip_signature_check" mapstructure:"insecure_skip_signature_check"`
- // Debug enables the OIDC debug mode. In debug mode, the received id_token will be logged
- // at the debug level
- Debug bool `json:"debug" mapstructure:"debug"`
- provider *oidc.Provider
- verifier OIDCTokenVerifier
- providerLogoutURL string
- oauth2Config OAuth2Config
- }
- func (o *OIDC) isEnabled() bool {
- return o.provider != nil
- }
- func (o *OIDC) hasRoles() bool {
- return o.isEnabled() && (o.RoleField != "" || o.ImplicitRoles)
- }
- func (o *OIDC) getForcedRole(audience string) string {
- if !o.ImplicitRoles {
- return ""
- }
- if audience == tokenAudienceWebAdmin {
- return adminRoleFieldValue
- }
- return ""
- }
- func (o *OIDC) getRedirectURL() string {
- url := o.RedirectBaseURL
- if strings.HasSuffix(o.RedirectBaseURL, "/") {
- url = strings.TrimSuffix(o.RedirectBaseURL, "/")
- }
- url += webOIDCRedirectPath
- logger.Debug(logSender, "", "oidc redirect URL: %q", url)
- return url
- }
- func (o *OIDC) initialize() error {
- if o.ConfigURL == "" {
- return nil
- }
- if o.UsernameField == "" {
- return errors.New("oidc: username field cannot be empty")
- }
- if o.RedirectBaseURL == "" {
- return errors.New("oidc: redirect base URL cannot be empty")
- }
- if !util.Contains(o.Scopes, oidc.ScopeOpenID) {
- return fmt.Errorf("oidc: required scope %q is not set", oidc.ScopeOpenID)
- }
- if o.ClientSecretFile != "" {
- secret, err := util.ReadConfigFromFile(o.ClientSecretFile, configurationDir)
- if err != nil {
- return err
- }
- o.ClientSecret = secret
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- provider, err := oidc.NewProvider(ctx, o.ConfigURL)
- if err != nil {
- return fmt.Errorf("oidc: unable to initialize provider for URL %q: %w", o.ConfigURL, err)
- }
- claims := make(map[string]any)
- // we cannot get an error here because the response body was already parsed as JSON
- // on provider creation
- provider.Claims(&claims) //nolint:errcheck
- endSessionEndPoint, ok := claims["end_session_endpoint"]
- if ok {
- if val, ok := endSessionEndPoint.(string); ok {
- o.providerLogoutURL = val
- logger.Debug(logSender, "", "oidc end session endpoint %q", o.providerLogoutURL)
- }
- }
- o.provider = provider
- o.verifier = nil
- o.oauth2Config = &oauth2.Config{
- ClientID: o.ClientID,
- ClientSecret: o.ClientSecret,
- Endpoint: o.provider.Endpoint(),
- RedirectURL: o.getRedirectURL(),
- Scopes: o.Scopes,
- }
- return nil
- }
- func (o *OIDC) getVerifier(ctx context.Context) OIDCTokenVerifier {
- if o.verifier != nil {
- return o.verifier
- }
- return o.provider.VerifierContext(ctx, &oidc.Config{
- ClientID: o.ClientID,
- InsecureSkipSignatureCheck: o.InsecureSkipSignatureCheck,
- })
- }
- type oidcPendingAuth struct {
- State string `json:"state"`
- Nonce string `json:"nonce"`
- Audience tokenAudience `json:"audience"`
- IssuedAt int64 `json:"issued_at"`
- }
- func newOIDCPendingAuth(audience tokenAudience) oidcPendingAuth {
- return oidcPendingAuth{
- State: xid.New().String(),
- Nonce: xid.New().String(),
- Audience: audience,
- IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
- }
- }
- type oidcToken struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type,omitempty"`
- RefreshToken string `json:"refresh_token,omitempty"`
- ExpiresAt int64 `json:"expires_at,omitempty"`
- SessionID string `json:"session_id"`
- IDToken string `json:"id_token"`
- Nonce string `json:"nonce"`
- Username string `json:"username"`
- Permissions []string `json:"permissions"`
- HideUserPageSections int `json:"hide_user_page_sections,omitempty"`
- TokenRole string `json:"token_role,omitempty"` // SFTPGo role name
- Role any `json:"role"` // oidc user role: SFTPGo user or admin
- CustomFields *map[string]any `json:"custom_fields,omitempty"`
- Cookie string `json:"cookie"`
- UsedAt int64 `json:"used_at"`
- }
- func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField string, customFields []string,
- forcedRole string,
- ) error {
- getClaimsFields := func() []string {
- keys := make([]string, 0, len(claims))
- for k := range claims {
- keys = append(keys, k)
- }
- return keys
- }
- var username string
- val, ok := getOIDCFieldFromClaims(claims, usernameField)
- if ok {
- username, ok = val.(string)
- }
- if !ok || username == "" {
- logger.Warn(logSender, "", "username field %q not found, empty or not a string, claims fields: %+v",
- usernameField, getClaimsFields())
- return errors.New("no username field")
- }
- t.Username = username
- if forcedRole != "" {
- t.Role = forcedRole
- } else {
- t.getRoleFromField(claims, roleField)
- }
- t.CustomFields = nil
- if len(customFields) > 0 {
- for _, field := range customFields {
- if val, ok := getOIDCFieldFromClaims(claims, field); ok {
- if t.CustomFields == nil {
- customFields := make(map[string]any)
- t.CustomFields = &customFields
- }
- logger.Debug(logSender, "", "custom field %q found in token claims", field)
- (*t.CustomFields)[field] = val
- } else {
- logger.Info(logSender, "", "custom field %q not found in token claims", field)
- }
- }
- }
- sid, ok := claims["sid"].(string)
- if ok {
- t.SessionID = sid
- }
- return nil
- }
- func (t *oidcToken) getRoleFromField(claims map[string]any, roleField string) {
- role, ok := getOIDCFieldFromClaims(claims, roleField)
- if ok {
- t.Role = role
- }
- }
- func (t *oidcToken) isAdmin() bool {
- switch v := t.Role.(type) {
- case string:
- return v == adminRoleFieldValue
- case []any:
- for _, s := range v {
- if val, ok := s.(string); ok && val == adminRoleFieldValue {
- return true
- }
- }
- return false
- default:
- return false
- }
- }
- func (t *oidcToken) isExpired() bool {
- if t.ExpiresAt == 0 {
- return false
- }
- return t.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now())
- }
- func (t *oidcToken) refresh(ctx context.Context, config OAuth2Config, verifier OIDCTokenVerifier, r *http.Request) error {
- if t.RefreshToken == "" {
- logger.Debug(logSender, "", "refresh token not set, unable to refresh cookie %q", t.Cookie)
- return errors.New("refresh token not set")
- }
- oauth2Token := oauth2.Token{
- AccessToken: t.AccessToken,
- TokenType: t.TokenType,
- RefreshToken: t.RefreshToken,
- }
- if t.ExpiresAt > 0 {
- oauth2Token.Expiry = util.GetTimeFromMsecSinceEpoch(t.ExpiresAt)
- }
- newToken, err := config.TokenSource(ctx, &oauth2Token).Token()
- if err != nil {
- logger.Debug(logSender, "", "unable to refresh token for cookie %q: %v", t.Cookie, err)
- return err
- }
- rawIDToken, ok := newToken.Extra("id_token").(string)
- if !ok {
- logger.Debug(logSender, "", "the refreshed token has no id token, cookie %q", t.Cookie)
- return errors.New("the refreshed token has no id token")
- }
- t.AccessToken = newToken.AccessToken
- t.TokenType = newToken.TokenType
- t.RefreshToken = newToken.RefreshToken
- t.IDToken = rawIDToken
- if !newToken.Expiry.IsZero() {
- t.ExpiresAt = util.GetTimeAsMsSinceEpoch(newToken.Expiry)
- } else {
- t.ExpiresAt = 0
- }
- idToken, err := verifier.Verify(ctx, rawIDToken)
- if err != nil {
- logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %q: %v", t.Cookie, err)
- return err
- }
- if idToken.Nonce != t.Nonce {
- logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %q: nonce mismatch", t.Cookie)
- return errors.New("the refreshed token nonce mismatch")
- }
- claims := make(map[string]any)
- err = idToken.Claims(&claims)
- if err != nil {
- logger.Debug(logSender, "", "unable to get refreshed id token claims for cookie %q: %v", t.Cookie, err)
- return err
- }
- sid, ok := claims["sid"].(string)
- if ok {
- t.SessionID = sid
- }
- err = t.refreshUser(r)
- if err != nil {
- logger.Debug(logSender, "", "unable to refresh user after token refresh for cookie %q: %v", t.Cookie, err)
- return err
- }
- logger.Debug(logSender, "", "oidc token refreshed for user %q, cookie %q", t.Username, t.Cookie)
- oidcMgr.addToken(*t)
- return nil
- }
- func (t *oidcToken) refreshUser(r *http.Request) error {
- if t.isAdmin() {
- admin, err := dataprovider.AdminExists(t.Username)
- if err != nil {
- return err
- }
- if err := admin.CanLogin(util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
- return err
- }
- t.Permissions = admin.Permissions
- t.TokenRole = admin.Role
- t.HideUserPageSections = admin.Filters.Preferences.HideUserPageSections
- return nil
- }
- user, err := dataprovider.GetUserWithGroupSettings(t.Username, "")
- if err != nil {
- return err
- }
- if err := user.CheckLoginConditions(); err != nil {
- return err
- }
- if err := checkHTTPClientUser(&user, r, xid.New().String(), true); err != nil {
- return err
- }
- t.Permissions = user.Filters.WebClient
- t.TokenRole = user.Role
- return nil
- }
- func (t *oidcToken) getUser(r *http.Request) error {
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- params := common.EventParams{
- Name: t.Username,
- IP: ipAddr,
- Protocol: common.ProtocolOIDC,
- Timestamp: time.Now().UnixNano(),
- Status: 1,
- }
- if t.isAdmin() {
- params.Event = common.IDPLoginAdmin
- _, admin, err := common.HandleIDPLoginEvent(params, t.CustomFields)
- if err != nil {
- return err
- }
- if admin == nil {
- a, err := dataprovider.AdminExists(t.Username)
- if err != nil {
- return err
- }
- admin = &a
- }
- if err := admin.CanLogin(ipAddr); err != nil {
- return err
- }
- t.Permissions = admin.Permissions
- t.TokenRole = admin.Role
- t.HideUserPageSections = admin.Filters.Preferences.HideUserPageSections
- dataprovider.UpdateAdminLastLogin(admin)
- return nil
- }
- params.Event = common.IDPLoginUser
- user, _, err := common.HandleIDPLoginEvent(params, t.CustomFields)
- if err != nil {
- return err
- }
- if user == nil {
- u, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC, t.CustomFields)
- if err != nil {
- return err
- }
- user = &u
- }
- if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolOIDC); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
- return fmt.Errorf("access denied: %w", err)
- }
- if err := user.CheckLoginConditions(); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
- return err
- }
- connectionID := fmt.Sprintf("%s_%s", common.ProtocolOIDC, xid.New().String())
- if err := checkHTTPClientUser(user, r, connectionID, true); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
- return err
- }
- defer user.CloseFs() //nolint:errcheck
- err = user.CheckFsRoot(connectionID)
- if err != nil {
- logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, common.ErrInternalFailure)
- return err
- }
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, nil)
- dataprovider.UpdateLastLogin(user)
- t.Permissions = user.Filters.WebClient
- t.TokenRole = user.Role
- return nil
- }
- func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request, isAdmin bool) (oidcToken, error) {
- doRedirect := func() {
- removeOIDCCookie(w, r)
- if isAdmin {
- http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
- return
- }
- http.Redirect(w, r, webClientLoginPath, http.StatusFound)
- }
- cookie, err := r.Cookie(oidcCookieKey)
- if err != nil {
- logger.Debug(logSender, "", "no oidc cookie, redirecting to login page")
- doRedirect()
- return oidcToken{}, errInvalidToken
- }
- token, err := oidcMgr.getToken(cookie.Value)
- if err != nil {
- logger.Debug(logSender, "", "error getting oidc token associated with cookie %q: %v", cookie.Value, err)
- doRedirect()
- return oidcToken{}, errInvalidToken
- }
- if token.isExpired() {
- logger.Debug(logSender, "", "oidc token associated with cookie %q is expired", token.Cookie)
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
- if err = token.refresh(ctx, s.binding.OIDC.oauth2Config, s.binding.OIDC.getVerifier(ctx), r); err != nil {
- setFlashMessage(w, r, "Your OpenID token is expired, please log-in again")
- doRedirect()
- return oidcToken{}, errInvalidToken
- }
- } else {
- oidcMgr.updateTokenUsage(token)
- }
- if isAdmin {
- if !token.isAdmin() {
- logger.Debug(logSender, "", "oidc token associated with cookie %q is not valid for admin users", token.Cookie)
- setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin")
- doRedirect()
- return oidcToken{}, errInvalidToken
- }
- return token, nil
- }
- if token.isAdmin() {
- logger.Debug(logSender, "", "oidc token associated with cookie %q is valid for admin users", token.Cookie)
- setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user")
- doRedirect()
- return oidcToken{}, errInvalidToken
- }
- return token, nil
- }
- func (s *httpdServer) oidcTokenAuthenticator(audience tokenAudience) func(next http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if canSkipOIDCValidation(r) {
- next.ServeHTTP(w, r)
- return
- }
- token, err := s.validateOIDCToken(w, r, audience == tokenAudienceWebAdmin)
- if err != nil {
- return
- }
- jwtTokenClaims := jwtTokenClaims{
- Username: token.Username,
- Permissions: token.Permissions,
- Role: token.TokenRole,
- HideUserPageSections: token.HideUserPageSections,
- }
- _, tokenString, err := jwtTokenClaims.createToken(s.tokenAuth, audience, util.GetIPFromRemoteAddress(r.RemoteAddr))
- if err != nil {
- setFlashMessage(w, r, "Unable to create cookie")
- if audience == tokenAudienceWebAdmin {
- http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
- } else {
- http.Redirect(w, r, webClientLoginPath, http.StatusFound)
- }
- return
- }
- ctx := context.WithValue(r.Context(), oidcTokenKey, token.Cookie)
- ctx = context.WithValue(ctx, oidcGeneratedToken, tokenString)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
- }
- }
- func (s *httpdServer) handleWebAdminOIDCLogin(w http.ResponseWriter, r *http.Request) {
- s.oidcLoginRedirect(w, r, tokenAudienceWebAdmin)
- }
- func (s *httpdServer) handleWebClientOIDCLogin(w http.ResponseWriter, r *http.Request) {
- s.oidcLoginRedirect(w, r, tokenAudienceWebClient)
- }
- func (s *httpdServer) oidcLoginRedirect(w http.ResponseWriter, r *http.Request, audience tokenAudience) {
- pendingAuth := newOIDCPendingAuth(audience)
- oidcMgr.addPendingAuth(pendingAuth)
- http.Redirect(w, r, s.binding.OIDC.oauth2Config.AuthCodeURL(pendingAuth.State,
- oidc.Nonce(pendingAuth.Nonce)), http.StatusFound)
- }
- func (s *httpdServer) debugTokenClaims(claims map[string]any, rawIDToken string) {
- if s.binding.OIDC.Debug {
- if claims == nil {
- logger.Debug(logSender, "", "raw id token %q", rawIDToken)
- } else {
- logger.Debug(logSender, "", "raw id token %q, parsed claims %+v", rawIDToken, claims)
- }
- }
- }
- func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) {
- state := r.URL.Query().Get("state")
- authReq, err := oidcMgr.getPendingAuth(state)
- if err != nil {
- logger.Debug(logSender, "", "oidc authentication state did not match")
- s.renderClientMessagePage(w, r, util.I18nInvalidAuthReqTitle, http.StatusBadRequest,
- util.NewI18nError(err, util.I18nInvalidAuth), "")
- return
- }
- oidcMgr.removePendingAuth(state)
- doRedirect := func() {
- if authReq.Audience == tokenAudienceWebAdmin {
- http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
- return
- }
- http.Redirect(w, r, webClientLoginPath, http.StatusFound)
- }
- doLogout := func(rawIDToken string) {
- s.logoutFromOIDCOP(rawIDToken)
- }
- ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
- defer cancel()
- oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
- if err != nil {
- logger.Debug(logSender, "", "failed to exchange oidc token: %v", err)
- setFlashMessage(w, r, "Failed to exchange OpenID token")
- doRedirect()
- return
- }
- rawIDToken, ok := oauth2Token.Extra("id_token").(string)
- if !ok {
- logger.Debug(logSender, "", "no id_token field in OAuth2 OpenID token")
- setFlashMessage(w, r, "No id_token field in OAuth2 OpenID token")
- doRedirect()
- return
- }
- s.debugTokenClaims(nil, rawIDToken)
- idToken, err := s.binding.OIDC.getVerifier(ctx).Verify(ctx, rawIDToken)
- if err != nil {
- logger.Debug(logSender, "", "failed to verify oidc token: %v", err)
- setFlashMessage(w, r, "Failed to verify OpenID token")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- if idToken.Nonce != authReq.Nonce {
- logger.Debug(logSender, "", "oidc authentication nonce did not match")
- setFlashMessage(w, r, "OpenID authentication nonce did not match")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- claims := make(map[string]any)
- err = idToken.Claims(&claims)
- if err != nil {
- logger.Debug(logSender, "", "unable to get oidc token claims: %v", err)
- setFlashMessage(w, r, "Unable to get OpenID token claims")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- s.debugTokenClaims(claims, rawIDToken)
- token := oidcToken{
- AccessToken: oauth2Token.AccessToken,
- TokenType: oauth2Token.TokenType,
- RefreshToken: oauth2Token.RefreshToken,
- IDToken: rawIDToken,
- Nonce: idToken.Nonce,
- Cookie: xid.New().String(),
- }
- if !oauth2Token.Expiry.IsZero() {
- token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
- }
- err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField,
- s.binding.OIDC.CustomFields, s.binding.OIDC.getForcedRole(authReq.Audience))
- if err != nil {
- logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
- setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- switch authReq.Audience {
- case tokenAudienceWebAdmin:
- if !token.isAdmin() {
- logger.Debug(logSender, "", "wrong oidc token role, the mapped user is not an SFTPGo admin")
- setFlashMessage(w, r, "Wrong OpenID role, the logged in user is not an SFTPGo admin")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- case tokenAudienceWebClient:
- if token.isAdmin() {
- logger.Debug(logSender, "", "wrong oidc token role, the mapped user is an SFTPGo admin")
- setFlashMessage(w, r, "Wrong OpenID role, the logged in user is an SFTPGo admin")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- }
- err = token.getUser(r)
- if err != nil {
- logger.Debug(logSender, "", "unable to get the sftpgo user associated with oidc token: %v", err)
- setFlashMessage(w, r, "Unable to get the user associated with the OpenID token")
- doRedirect()
- doLogout(rawIDToken)
- return
- }
- loginOIDCUser(w, r, token)
- }
- func loginOIDCUser(w http.ResponseWriter, r *http.Request, token oidcToken) {
- oidcMgr.addToken(token)
- cookie := http.Cookie{
- Name: oidcCookieKey,
- Value: token.Cookie,
- Path: "/",
- HttpOnly: true,
- Secure: isTLS(r),
- SameSite: http.SameSiteLaxMode,
- }
- // we don't set a cookie expiration so we can refresh the token without setting a new cookie
- // the cookie will be invalidated on browser close
- http.SetCookie(w, &cookie)
- w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`)
- if token.isAdmin() {
- http.Redirect(w, r, webUsersPath, http.StatusFound)
- return
- }
- http.Redirect(w, r, webClientFilesPath, http.StatusFound)
- }
- func (s *httpdServer) logoutOIDCUser(w http.ResponseWriter, r *http.Request) {
- if oidcKey, ok := r.Context().Value(oidcTokenKey).(string); ok {
- removeOIDCCookie(w, r)
- token, err := oidcMgr.getToken(oidcKey)
- if err == nil {
- s.logoutFromOIDCOP(token.IDToken)
- }
- oidcMgr.removeToken(oidcKey)
- }
- }
- func (s *httpdServer) logoutFromOIDCOP(idToken string) {
- if s.binding.OIDC.providerLogoutURL == "" {
- logger.Debug(logSender, "", "oidc: provider logout URL not set, unable to logout from the OP")
- return
- }
- go s.doOIDCFromLogout(idToken)
- }
- func (s *httpdServer) doOIDCFromLogout(idToken string) {
- logoutURL, err := url.Parse(s.binding.OIDC.providerLogoutURL)
- if err != nil {
- logger.Warn(logSender, "", "oidc: unable to parse logout URL: %v", err)
- return
- }
- query := logoutURL.Query()
- if idToken != "" {
- query.Set("id_token_hint", idToken)
- }
- logoutURL.RawQuery = query.Encode()
- resp, err := httpclient.RetryableGet(logoutURL.String())
- if err != nil {
- logger.Warn(logSender, "", "oidc: error calling logout URL %q: %v", logoutURL.String(), err)
- return
- }
- defer resp.Body.Close()
- logger.Debug(logSender, "", "oidc: logout url response code %v", resp.StatusCode)
- }
- func removeOIDCCookie(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{
- Name: oidcCookieKey,
- Value: "",
- Path: "/",
- Expires: time.Unix(0, 0),
- MaxAge: -1,
- HttpOnly: true,
- Secure: isTLS(r),
- SameSite: http.SameSiteLaxMode,
- })
- }
- // canSkipOIDCValidation returns true if there is no OIDC cookie but a jwt cookie is set
- // and so we check if the user is logged in using a built-in user
- func canSkipOIDCValidation(r *http.Request) bool {
- _, err := r.Cookie(oidcCookieKey)
- if err != nil {
- _, err = r.Cookie(jwtCookieKey)
- return err == nil
- }
- return false
- }
- func isLoggedInWithOIDC(r *http.Request) bool {
- _, ok := r.Context().Value(oidcTokenKey).(string)
- return ok
- }
- func getOIDCFieldFromClaims(claims map[string]any, fieldName string) (any, bool) {
- if fieldName == "" {
- return nil, false
- }
- val, ok := claims[fieldName]
- if ok {
- return val, true
- }
- if !strings.Contains(fieldName, ".") {
- return nil, false
- }
- getStructValue := func(outer any, field string) (any, bool) {
- switch v := outer.(type) {
- case map[string]any:
- res, ok := v[field]
- return res, ok
- }
- return nil, false
- }
- for idx, field := range strings.Split(fieldName, ".") {
- if idx == 0 {
- val, ok = getStructValue(claims, field)
- } else {
- val, ok = getStructValue(val, field)
- }
- if !ok {
- return nil, false
- }
- }
- return val, ok
- }
|