package handlers import ( "context" "encoding/gob" "fmt" "html/template" "io" "mime/multipart" "net/http" "net/url" "strings" "time" "golang.org/x/text/language" "github.com/cortezaproject/corteza/server/system/service" "github.com/cortezaproject/corteza/server/auth/external" "github.com/cortezaproject/corteza/server/auth/request" "github.com/cortezaproject/corteza/server/auth/saml" "github.com/cortezaproject/corteza/server/auth/settings" "github.com/cortezaproject/corteza/server/pkg/auth" "github.com/cortezaproject/corteza/server/pkg/errors" "github.com/cortezaproject/corteza/server/pkg/locale" "github.com/cortezaproject/corteza/server/pkg/options" "github.com/cortezaproject/corteza/server/system/types" "github.com/go-oauth2/oauth2/v4" "github.com/go-oauth2/oauth2/v4/server" "github.com/gorilla/csrf" "github.com/gorilla/sessions" "github.com/markbates/goth" "github.com/spf13/cast" "go.uber.org/zap" ) type ( authService interface { External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error) InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error) SetPassword(ctx context.Context, userID uint64, password string) (err error) ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) SendPasswordResetToken(ctx context.Context, email string) (err error) GetProviders() types.ExternalAuthProviderSet PasswordSet(ctx context.Context, email string) (is bool) ValidateTOTP(ctx context.Context, code string) (err error) ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error) RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error) LoadRoleMemberships(ctx context.Context, u *types.User) error SendEmailOTP(ctx context.Context) (err error) ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error) ValidateEmailOTP(ctx context.Context, code string) (err error) SendInviteEmail(ctx context.Context, email string) (err error) ValidateInviteEmailToken(ctx context.Context, token string) (user *types.User, err error) } credentialsService interface { List(ctx context.Context, userID uint64) (cc types.CredentialSet, err error) Create(ctx context.Context, c *types.Credential) (*types.Credential, error) Update(ctx context.Context, c *types.Credential) (*types.Credential, error) Delete(ctx context.Context, userID, credentialsID uint64) (err error) } userService interface { FindByAny(ctx context.Context, identifier interface{}) (*types.User, error) Update(context.Context, *types.User) (*types.User, error) UploadAvatar(ctx context.Context, userID uint64, upload *multipart.FileHeader) (err error) GenerateAvatar(ctx context.Context, userID uint64, bgColor string, initialColor string) (err error) DeleteAvatar(ctx context.Context, userID uint64) (err error) } clientService interface { Lookup(context.Context, interface{}) (*types.AuthClient, error) Confirmed(context.Context, uint64) (types.AuthConfirmedClientSet, error) Revoke(ctx context.Context, userID, clientID uint64) error } // @todo this should probably be a little more decoupled from the store and nicely named tokenService interface { SearchByUserID(ctx context.Context, userID uint64) (types.AuthOa2tokenSet, error) DeleteByID(ctx context.Context, ID uint64) error DeleteByUserID(ctx context.Context, userID uint64) error } templateExecutor interface { ExecuteTemplate(io.Writer, string, interface{}, ...func(template *template.Template) *template.Template) error } oauth2Service interface { GetRedirectURI(req *server.AuthorizeRequest, data map[string]interface{}) (string, error) CheckResponseType(rt oauth2.ResponseType) bool CheckCodeChallengeMethod(ccm oauth2.CodeChallengeMethod) bool ValidationAuthorizeRequest(r *http.Request) (*server.AuthorizeRequest, error) GetAuthorizeToken(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error) GetAuthorizeData(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{} HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error ValidationTokenRequest(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error) CheckGrantType(gt oauth2.GrantType) bool GetAccessToken(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error) GetTokenData(ti oauth2.TokenInfo) map[string]interface{} HandleTokenRequest(w http.ResponseWriter, r *http.Request) error GetErrorData(err error) (map[string]interface{}, int, http.Header) BearerAuth(r *http.Request) (string, bool) ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error) } localeService interface { NS(ctx context.Context, ns string) func(key string, rr ...string) string T(ctx context.Context, ns, key string, rr ...string) string HasLanguage(lang language.Tag) bool LocalizedList(ctx context.Context) []*locale.Language } AuthHandlers struct { Log *zap.Logger Locale localeService Templates templateExecutor OAuth2 oauth2Service SessionManager *request.SessionManager AuthService authService CredentialsService credentialsService UserService userService ClientService clientService TokenService tokenService DefaultClient *types.AuthClient Opt options.AuthOpt Settings *settings.Settings SamlSPService *saml.SamlSPService Attachment service.AttachmentService } handlerFn func(req *request.AuthReq) error ) const ( TmplAuthorizedClients = "authorized-clients.html.tpl" TmplChangePassword = "change-password.html.tpl" TmplCreatePassword = "create-password.html.tpl" TmplLogin = "login.html.tpl" TmplLogout = "logout.html.tpl" TmplOAuth2AuthorizeClient = "oauth2-authorize-client.html.tpl" TmplRequestPasswordReset = "request-password-reset.html.tpl" TmplPasswordResetRequested = "password-reset-requested.html.tpl" TmplResetPassword = "reset-password.html.tpl" TmplInvite = "invite.html.tpl" TmplSecurity = "security.html.tpl" TmplProfile = "profile.html.tpl" TmplSessions = "sessions.html.tpl" TmplSignup = "signup.html.tpl" TmplPendingEmailConfirmation = "pending-email-confirmation.html.tpl" TmplMfa = "mfa.html.tpl" TmplMfaTotp = "mfa-totp.html.tpl" TmplMfaTotpDisable = "mfa-totp-disable.html.tpl" TmplInternalError = "error-internal.html.tpl" // 1k of data per POST field is all we allow maxPostValueLength = 2 << 9 // general limitation on number of fields maxPostFields = 10 ) var ( // wrapper around time.Now() that will aid service testing now = func() *time.Time { c := time.Now() return &c } ) func init() { gob.Register(&types.User{}) gob.Register(&types.AuthClient{}) gob.Register([]request.Alert{}) gob.Register(url.Values{}) } // handles auth request and prepares request struct with request, session and response helper func (h *AuthHandlers) handle(fn handlerFn) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var ( req = &request.AuthReq{ Response: w, Request: r, Data: make(map[string]interface{}), NewAlerts: make([]request.Alert, 0), PrevAlerts: make([]request.Alert, 0), Session: h.SessionManager.Get(r), Locale: h.Locale, } ) h.Log.Debug( "handling request", zap.String("url", r.RequestURI), zap.String("method", r.Method), ) err := func() (err error) { if err = r.ParseForm(); err != nil { return } // Caching 32MB to memory, the rest to disk err = r.ParseMultipartForm(32 << 20) if err != nil && err != http.ErrNotMultipart { return } if !validFormPost(r) { req.Status = http.StatusRequestEntityTooLarge return } req.Client = request.GetOauth2Client(req.Session) req.AuthUser = request.GetAuthUser(req.Session) // make sure user (identity) is part of the context // so we can properly identify ourselves when interacting // with services, and set user's preferred language if req.AuthUser != nil && !req.AuthUser.PendingMFA() { req.Request = req.Request.Clone(auth.SetIdentityToContext( req.Context(), auth.Authenticated(req.AuthUser.User.ID, req.AuthUser.User.Roles()...), )) userPreferredLanguage := language.Make(req.AuthUser.User.Meta.PreferredLanguage) // set user's preferred language if h.Locale.HasLanguage(userPreferredLanguage) { ctx := req.Request.Context() ctx = locale.SetAcceptLanguageToContext(ctx, userPreferredLanguage) ctx = locale.SetContentLanguageToContext(ctx, userPreferredLanguage) req.Request = req.Request.WithContext(ctx) } } // Alerts show for 1 session only! req.PrevAlerts = req.PopAlerts() if err = fn(req); err != nil { h.Log.Error("error in handler", zap.Error(err)) } if req.RedirectTo != "" && len(req.PrevAlerts) > 0 { // redirect happened, so probably none noticed alerts // lets push them at the end of new alerts req.NewAlerts = append(req.NewAlerts, req.PrevAlerts...) } if len(req.NewAlerts) > 0 { req.SetAlerts(req.NewAlerts...) } if err = sessions.Save(r, w); err != nil { h.Log.Error("could not save session", zap.Error(err)) } if req.Status == 0 { switch { case req.RedirectTo != "": req.Status = http.StatusSeeOther req.Template = "" case req.Template != "": req.Status = http.StatusOK default: req.Status = http.StatusInternalServerError req.Template = TmplInternalError } } return nil }() if err == nil { if req.Status >= 300 && req.Status < 400 { // redirect, nothing special to handle http.Redirect(w, r, req.RedirectTo, req.Status) return } if req.Status > 0 { // in cases when something else already wrote the status w.WriteHeader(req.Status) } } var ( // translator template function // // we're adding these functions on every request on all templates // because we need request's context to detect the language from! ttf = func(t *template.Template) *template.Template { return t.Funcs(map[string]interface{}{ "language": func() string { return locale.GetAcceptLanguageFromContext(req.Context()).String() }, "tr": func(key string, pp ...interface{}) template.HTML { ss := make([]string, len(pp)) for i := range pp { ss[i] = cast.ToString(pp[i]) } return template.HTML(h.Locale.T(req.Context(), "auth", key, ss...)) }, }) } ) // Handling just text/html response types from here on // // If handler does not wish to use the template leave/set it to "" (empty string) if err == nil && req.Template != "" { err = h.Templates.ExecuteTemplate(w, req.Template, h.enrichTmplData(req), ttf, ) h.Log.Debug("template executed", zap.String("name", req.Template), zap.Error(err)) } if err != nil { err = h.Templates.ExecuteTemplate(w, TmplInternalError, map[string]interface{}{ "error": err, }, ttf) if err == nil { return } } if err != nil { h.Log.Error("unhandled error", zap.Error(err)) http.Error(w, err.Error(), http.StatusInternalServerError) } } } // Add alerts, settings, providers, csrf token, Bg func (h *AuthHandlers) enrichTmplData(req *request.AuthReq) interface{} { d := req.Data d["theme"] = "light" if req.AuthUser != nil { maskEmail := service.CurrentSettings.Privacy.Mask.Email maskName := service.CurrentSettings.Privacy.Mask.Name service.CurrentSettings.Privacy.Mask.Email = false service.CurrentSettings.Privacy.Mask.Name = false // fetch current user with updated fields user, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID) service.CurrentSettings.Privacy.Mask.Email = maskEmail service.CurrentSettings.Privacy.Mask.Name = maskName // check if err is not nil and cater for MFA by checking if the error is not allowed to read if err != nil && !errors.Is(err, service.UserErrNotAllowedToRead()) { return err } d["user"] = user d["theme"] = user.Meta.Theme } if req.Client != nil { c := authClient{ ID: req.Client.ID, Name: req.Client.Handle, } if req.Client.Meta != nil { c.Name = req.Client.Meta.Name c.Description = req.Client.Meta.Description } d["client"] = c } d[csrf.TemplateTag] = csrf.TemplateField(req.Request) // In case we did not redirect, join previous alerts with new ones d["alerts"] = append(req.PrevAlerts, req.NewAlerts...) dSettings := *h.Settings var pp = make([]provider, 0, len(dSettings.Providers)) if h.Settings.Saml.Enabled { pp = append(pp, provider(saml.TemplateProvider(h.Settings.Saml.IDP.URL, h.Settings.Saml.Name))) } for _, p := range dSettings.Providers { if _, err := goth.GetProvider(p.Handle); err != nil { continue } // Skipping the ones we don't use for identity if !p.HasUsage(types.ExternalProviderUsageIdentity) { continue } out := provider{ Label: p.Label, Handle: p.Handle, Icon: p.Handle, } if strings.HasPrefix(out.Icon, external.OIDC_PROVIDER_PREFIX) { out.Icon = "key" } pp = append(pp, out) } d["providers"] = pp dSettings.Providers = nil d["settings"] = dSettings d["authBg"] = template.CSS(h.bgStylesData()) return d } func (h *AuthHandlers) bgStylesData() string { if h.Settings.BackgroundUI.BackgroundImageSrcUrl == "" { return fmt.Sprintf("background: url(%s/release-background.png) no-repeat top; %s", GetLinks().Assets, h.Settings.BackgroundUI.Styles) } return fmt.Sprintf("background: url('%s') no-repeat top; %s", h.Settings.BackgroundUI.BackgroundImageSrcUrl, h.Settings.BackgroundUI.Styles) } // Handle successful auth (on any factor) func handleSuccessfulAuth(req *request.AuthReq) { switch { case req.AuthUser.PendingMFA(): req.RedirectTo = GetLinks().Mfa case request.GetOAuth2AuthParams(req.Session) != nil: // client authorization flow was paused, continue. req.RedirectTo = GetLinks().OAuth2AuthorizeClient default: // Always go to profile req.RedirectTo = GetLinks().Profile } } // redirects anonymous users to login func authOnly(fn handlerFn) handlerFn { return func(req *request.AuthReq) error { // these next few lines keep users away from the pages they should not see // and redirect them to where they need to be switch { case req.AuthUser == nil || req.AuthUser.User == nil: // not authenticated at all, move to login req.RedirectTo = GetLinks().Login case req.AuthUser.UnconfiguredTOTP(): // authenticated but need to configure MFA req.RedirectTo = GetLinks().MfaTotpNewSecret case req.AuthUser.PendingMFA(): // authenticated but MFA pending req.RedirectTo = GetLinks().Mfa default: return fn(req) } return nil } } func partAuthOnly(fn handlerFn) handlerFn { return func(req *request.AuthReq) error { if req.AuthUser == nil || req.AuthUser.User == nil { req.RedirectTo = GetLinks().Login return nil } else { return fn(req) } } } // redirects authenticated users to profile func anonyOnly(fn handlerFn) handlerFn { return func(req *request.AuthReq) error { if req.AuthUser != nil && req.AuthUser.User != nil { req.RedirectTo = GetLinks().Profile return nil } else { return fn(req) } } } func translator(req *request.AuthReq, ns string) func(key string, rr ...string) string { return req.Locale.NS(req.Context(), ns) } // general validation of posted data // // quite primitive for now but should be effective against out-of-bounds attacks // // in the future, more sophisticated validation might be needed func validFormPost(r *http.Request) bool { if len(r.Form) > maxPostFields { // auth does not have any large forms return false } // None of the values from the post fields should be longer than max length for k, _ := range r.Form { if len(r.Form[k]) > 1 { // assuming only one value per field! return false } if len(r.Form[k][0]) > maxPostValueLength { return false } } return true }