mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
Backport https://github.com/go-gitea/gitea/pull/40121 With `[oauth2_client].ACCOUNT_LINKING = auto`, the link account sign-up form linked the external identity to an existing account matching the submitted user name or email and signed in as it. Automatic linking now only happens when the existing user matches the external identity, otherwise the linking login page asks for the account's password. `release/v28` lacks `TestOAuth2AvatarFromPicture`, so only the new test is added. Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: Giteabot <teabot@gitea.io>
967 lines
31 KiB
Go
967 lines
31 KiB
Go
// Copyright 2014 The Gogs Authors. All rights reserved.
|
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
audit_model "gitea.dev/models/audit"
|
|
"gitea.dev/models/auth"
|
|
"gitea.dev/models/db"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/auth/password"
|
|
"gitea.dev/modules/httplib"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/optional"
|
|
"gitea.dev/modules/session"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/templates"
|
|
"gitea.dev/modules/timeutil"
|
|
"gitea.dev/modules/util"
|
|
"gitea.dev/modules/web"
|
|
"gitea.dev/modules/web/middleware"
|
|
"gitea.dev/services/audit"
|
|
auth_service "gitea.dev/services/auth"
|
|
"gitea.dev/services/auth/source/oauth2"
|
|
"gitea.dev/services/context"
|
|
"gitea.dev/services/externalaccount"
|
|
"gitea.dev/services/forms"
|
|
"gitea.dev/services/mailer"
|
|
user_service "gitea.dev/services/user"
|
|
websocket_service "gitea.dev/services/websocket"
|
|
|
|
"github.com/markbates/goth"
|
|
)
|
|
|
|
const (
|
|
tplSignIn templates.TplName = "user/auth/signin" // for sign in page
|
|
tplSignUp templates.TplName = "user/auth/signup" // for sign up page
|
|
TplActivate templates.TplName = "user/auth/activate" // for activate user
|
|
TplActivatePrompt templates.TplName = "user/auth/activate_prompt" // for showing a message for user activation
|
|
)
|
|
|
|
type CommonAuthOptions struct {
|
|
EnableCaptcha bool
|
|
}
|
|
|
|
func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
|
|
ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
|
|
ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
|
|
|
|
// for OpenID Connect
|
|
ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
|
|
ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
|
|
|
|
if opt.EnableCaptcha {
|
|
ctx.Data["EnableCaptcha"] = true
|
|
ctx.Data["RecaptchaAPIScriptURL"] = strings.TrimSuffix(setting.Service.RecaptchaURL, "/") + "/api.js"
|
|
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
|
|
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
|
|
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
|
|
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
|
|
ctx.Data["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
|
|
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
|
|
if setting.Service.CaptchaType == setting.ImageCaptcha {
|
|
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
|
}
|
|
}
|
|
}
|
|
|
|
// autoSignIn reads cookie and try to auto-login.
|
|
func autoSignIn(ctx *context.Context) (bool, error) {
|
|
keepRememberCookie := false
|
|
defer func() {
|
|
if !keepRememberCookie {
|
|
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
|
}
|
|
}()
|
|
|
|
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
|
|
log.Error("Failed to delete expired auth tokens: %v", err)
|
|
}
|
|
|
|
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
|
|
if err != nil {
|
|
switch err {
|
|
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if t == nil {
|
|
return false, nil
|
|
}
|
|
|
|
u, err := user_model.GetUserByID(ctx, t.UserID)
|
|
if err != nil {
|
|
if !user_model.IsErrUserNotExist(err) {
|
|
return false, fmt.Errorf("GetUserByID: %w", err)
|
|
}
|
|
return false, nil
|
|
}
|
|
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err)
|
|
}
|
|
|
|
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
|
|
if errors.Is(err, auth_service.ErrAuthTokenRotationConflict) {
|
|
keepRememberCookie = true // A late response must not clear the winner's cookie.
|
|
return false, nil
|
|
}
|
|
if errors.Is(err, auth_service.ErrAuthTokenExpired) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
|
keepRememberCookie = true
|
|
|
|
if err := regenerateSession(ctx, map[string]any{
|
|
session.KeyUID: u.ID,
|
|
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
|
}); err != nil {
|
|
return false, fmt.Errorf("unable to updateSession: %w", err)
|
|
}
|
|
|
|
if err := resetLocale(ctx, u); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func resetLocale(ctx *context.Context, u *user_model.User) error {
|
|
// Language setting of the user overwrites the one previously set
|
|
// If the user does not have a locale set, we save the current one.
|
|
if u.Language == "" {
|
|
opts := &user_service.UpdateOptions{
|
|
Language: optional.Some(ctx.Locale.Language()),
|
|
}
|
|
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
|
|
|
|
if ctx.Locale.Language() != u.Language {
|
|
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func rememberAuthRedirectLink(ctx *context.Context) {
|
|
redirectTo := ctx.FormString("redirect_to")
|
|
if redirectTo == "" {
|
|
if ref, err := url.Parse(ctx.Req.Referer()); err == nil && httplib.IsCurrentGiteaSiteURL(ctx, ctx.Req.Referer()) {
|
|
// the request paths starting with "/user/" are either:
|
|
// * auth related pages: don't redirect back to them
|
|
// * user settings pages: they have "require sign-in" protection already, no "referer redirect" would happen
|
|
skipRefererRedirect := strings.HasPrefix(ref.Path, setting.AppSubURL+"/user/")
|
|
if !skipRefererRedirect {
|
|
redirectTo = ref.RequestURI()
|
|
}
|
|
}
|
|
}
|
|
if redirectTo != "" {
|
|
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
|
}
|
|
}
|
|
|
|
func consumeAuthRedirectLink(ctx *context.Context) string {
|
|
redirects := []string{ctx.FormString("redirect_to"), middleware.GetRedirectToCookie(ctx.Req)}
|
|
middleware.DeleteRedirectToCookie(ctx.Resp)
|
|
if setting.LandingPageURL == setting.LandingPageLogin {
|
|
redirects = append(redirects, setting.AppSubURL+"/") // do not cycle-redirect to the login page
|
|
} else {
|
|
redirects = append(redirects, setting.AppSubURL+string(setting.LandingPageURL))
|
|
}
|
|
for _, link := range redirects {
|
|
if link != "" && httplib.IsCurrentGiteaSiteURL(ctx, link) {
|
|
return link
|
|
}
|
|
}
|
|
return setting.AppSubURL + "/"
|
|
}
|
|
|
|
func redirectAfterAuth(ctx *context.Context) {
|
|
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
|
|
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
|
|
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
|
return
|
|
}
|
|
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
|
|
}
|
|
|
|
func performAutoLogin(ctx *context.Context) bool {
|
|
rememberAuthRedirectLink(ctx)
|
|
|
|
isSucceed, err := autoSignIn(ctx) // try to auto-login
|
|
if err != nil {
|
|
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
|
|
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
|
|
return false
|
|
}
|
|
ctx.ServerError("autoSignIn", err)
|
|
return true
|
|
}
|
|
|
|
if isSucceed {
|
|
redirectAfterAuth(ctx)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool {
|
|
// If only 1 OAuth provider is present and other login methods are disabled, redirect to the OAuth provider.
|
|
onlySingleOAuth2 := len(data.oauth2Providers) == 1 &&
|
|
!setting.Service.EnablePasswordSignInForm &&
|
|
!setting.Service.EnableOpenIDSignIn &&
|
|
!setting.Service.EnablePasskeyAuth &&
|
|
!data.enableSSPI
|
|
|
|
if !onlySingleOAuth2 {
|
|
return false
|
|
}
|
|
|
|
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName())
|
|
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
|
|
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
|
|
}
|
|
ctx.Redirect(skipToOAuthURL)
|
|
return true
|
|
}
|
|
|
|
type preparedSignInData struct {
|
|
oauth2Providers []oauth2.Provider
|
|
enableSSPI bool
|
|
}
|
|
|
|
func prepareSignInPageData(ctx *context.Context) (ret preparedSignInData) {
|
|
var err error
|
|
ret.enableSSPI = auth.IsSSPIEnabled(ctx)
|
|
ret.oauth2Providers, err = oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
|
if err != nil {
|
|
log.Error("Failed to get OAuth2 providers: %v", err)
|
|
}
|
|
ctx.Data["Title"] = ctx.Tr("sign_in")
|
|
ctx.Data["OAuth2Providers"] = ret.oauth2Providers
|
|
ctx.Data["Title"] = ctx.Tr("sign_in")
|
|
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login"
|
|
ctx.Data["PageIsSignIn"] = true
|
|
ctx.Data["PageIsLogin"] = true
|
|
ctx.Data["EnableSSPI"] = ret.enableSSPI
|
|
|
|
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
|
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin,
|
|
})
|
|
return ret
|
|
}
|
|
|
|
// SignIn render sign in page
|
|
func SignIn(ctx *context.Context) {
|
|
if performAutoLogin(ctx) {
|
|
return
|
|
}
|
|
if ctx.IsSigned {
|
|
redirectAfterAuth(ctx)
|
|
return
|
|
}
|
|
data := prepareSignInPageData(ctx)
|
|
if performAutoLoginOAuth2(ctx, &data) {
|
|
return
|
|
}
|
|
ctx.HTML(http.StatusOK, tplSignIn)
|
|
}
|
|
|
|
// SignInPost response for sign in request
|
|
func SignInPost(ctx *context.Context) {
|
|
if !setting.Service.EnablePasswordSignInForm {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
prepareSignInPageData(ctx)
|
|
if ctx.HasError() {
|
|
ctx.HTML(http.StatusOK, tplSignIn)
|
|
return
|
|
}
|
|
|
|
form := web.GetForm[*forms.SignInForm](ctx)
|
|
|
|
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
|
|
context.VerifyCaptcha(ctx, tplSignIn, form)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
}
|
|
|
|
u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password)
|
|
if err != nil {
|
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form)
|
|
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
|
|
} else if user_model.IsErrEmailAlreadyUsed(err) {
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplSignIn, &form)
|
|
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
|
|
} else if user_model.IsErrUserProhibitLogin(err) {
|
|
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
|
|
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
|
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
|
} else {
|
|
ctx.ServerError("UserSignIn", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Now handle 2FA:
|
|
// First of all if the source can skip local two fa we're done
|
|
if source.TwoFactorShouldSkip() {
|
|
handleSignIn(ctx, u, form.Remember)
|
|
return
|
|
}
|
|
|
|
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
|
|
// Instead, redirect them to the 2FA authentication page.
|
|
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
|
if err != nil {
|
|
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
|
return
|
|
}
|
|
if !hasTwoFactor {
|
|
handleSignIn(ctx, u, form.Remember)
|
|
return
|
|
}
|
|
|
|
handleTwoFactorRequired(ctx, u, form.Remember, nil)
|
|
}
|
|
|
|
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
|
|
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
|
|
maps.Copy(updates, extra)
|
|
if err := regenerateSession(ctx, updates); err != nil {
|
|
ctx.ServerError("RegenerateSession", err)
|
|
return
|
|
}
|
|
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
|
if err != nil {
|
|
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
|
|
return
|
|
}
|
|
if hasWebAuthn {
|
|
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
|
return
|
|
}
|
|
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
|
}
|
|
|
|
// This handles the final part of the sign-in process of the user.
|
|
func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
|
|
handleSignInFull(ctx, u, remember)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
redirectAfterAuth(ctx)
|
|
}
|
|
|
|
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
|
if remember {
|
|
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
|
if err != nil {
|
|
ctx.ServerError("CreateAuthTokenForUserID", err)
|
|
return
|
|
}
|
|
|
|
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
|
}
|
|
|
|
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
|
if err != nil {
|
|
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
|
return
|
|
}
|
|
|
|
auth_service.ClearSessionKeysForSignIn(ctx.Session)
|
|
if err := regenerateSession(ctx, map[string]any{
|
|
session.KeyUID: u.ID,
|
|
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
|
}); err != nil {
|
|
ctx.ServerError("RegenerateSession", err)
|
|
return
|
|
}
|
|
|
|
// Language setting of the user overwrites the one previously set
|
|
// If the user does not have a locale set, we save the current one.
|
|
if u.Language == "" {
|
|
opts := &user_service.UpdateOptions{
|
|
Language: optional.Some(ctx.Locale.Language()),
|
|
}
|
|
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
|
ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language()))
|
|
return
|
|
}
|
|
}
|
|
|
|
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
|
|
|
|
if ctx.Locale.Language() != u.Language {
|
|
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
|
|
}
|
|
|
|
// Register last login
|
|
if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
|
|
// It returns ("", nil) if the required field doesn't exist.
|
|
func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
|
|
switch setting.OAuth2Client.Username {
|
|
case setting.OAuth2UsernameEmail:
|
|
return user_model.NormalizeUserName(gothUser.Email)
|
|
case setting.OAuth2UsernamePreferredUsername:
|
|
if preferredUsername, ok := gothUser.RawData["preferred_username"].(string); ok {
|
|
return user_model.NormalizeUserName(preferredUsername)
|
|
}
|
|
return "", nil
|
|
case setting.OAuth2UsernameNickname:
|
|
return user_model.NormalizeUserName(gothUser.NickName)
|
|
default: // OAuth2UsernameUserid
|
|
return gothUser.UserID, nil
|
|
}
|
|
}
|
|
|
|
func oauth2IdentityMatchesUser(gothUser *goth.User, u *user_model.User) bool {
|
|
if gothUser.Email != "" && strings.EqualFold(gothUser.Email, u.Email) {
|
|
return true
|
|
}
|
|
uname, err := extractUserNameFromOAuth2(gothUser)
|
|
return err == nil && uname != "" && strings.EqualFold(uname, u.Name)
|
|
}
|
|
|
|
// HandleSignOut resets the session and sets the cookies
|
|
func HandleSignOut(ctx *context.Context) {
|
|
_ = ctx.Session.Flush()
|
|
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
|
|
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
|
middleware.DeleteRedirectToCookie(ctx.Resp)
|
|
}
|
|
|
|
// SignOut sign out from login status
|
|
func SignOut(ctx *context.Context) {
|
|
if ctx.Doer != nil {
|
|
websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID())
|
|
}
|
|
|
|
impersonator := audit.ImpersonatorFromContext(ctx)
|
|
|
|
exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session)
|
|
if err != nil {
|
|
ctx.ServerError("ExitImpersonatedUser", err)
|
|
return
|
|
}
|
|
if exitedImpersonated {
|
|
audit.RecordAs(ctx, impersonator, audit_model.UserImpersonationExit, ctx.Doer)
|
|
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
|
return
|
|
}
|
|
|
|
// prepare the sign-out URL before destroying the session
|
|
redirectTo := buildSignOutRedirectURL(ctx)
|
|
HandleSignOut(ctx)
|
|
ctx.Redirect(redirectTo)
|
|
}
|
|
|
|
func buildSignOutRedirectURL(ctx *context.Context) string {
|
|
if ctx.Doer != nil && shouldRedirectToOIDCEndSession(ctx) {
|
|
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
|
|
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
|
|
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
|
|
return setting.ReverseProxyLogoutRedirect
|
|
}
|
|
return setting.AppSubURL + "/"
|
|
}
|
|
|
|
// shouldRedirectToOIDCEndSession reports whether this session should end at the
|
|
// OIDC provider. Prefer the session sign-in method so an OAuth2-linked account
|
|
// that signed in with a password does not hit end_session_endpoint.
|
|
func shouldRedirectToOIDCEndSession(ctx *context.Context) bool {
|
|
return ctx.Session.Get(session.KeySignInMethod) == session.SignInMethodOAuth2
|
|
}
|
|
|
|
func prepareSignUpPageData(ctx *context.Context) bool {
|
|
ctx.Data["Title"] = ctx.Tr("sign_up")
|
|
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
|
|
ctx.Data["PageIsSignUp"] = true
|
|
ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx)
|
|
|
|
hasUsers, err := user_model.HasUsers(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("HasUsers", err)
|
|
return false
|
|
}
|
|
ctx.Data["IsFirstTimeRegistration"] = !hasUsers.HasAnyUser
|
|
|
|
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
|
if err != nil {
|
|
ctx.ServerError("GetOAuth2Providers", err)
|
|
return false
|
|
}
|
|
ctx.Data["OAuth2Providers"] = oauth2Providers
|
|
|
|
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
|
EnableCaptcha: setting.Service.EnableCaptcha,
|
|
})
|
|
|
|
// Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
|
|
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
|
|
|
|
return true
|
|
}
|
|
|
|
// SignUp render the register page
|
|
func SignUp(ctx *context.Context) {
|
|
if !prepareSignUpPageData(ctx) {
|
|
return
|
|
}
|
|
rememberAuthRedirectLink(ctx)
|
|
ctx.HTML(http.StatusOK, tplSignUp)
|
|
}
|
|
|
|
// SignUpPost response for sign up information submission
|
|
func SignUpPost(ctx *context.Context) {
|
|
if !prepareSignUpPageData(ctx) {
|
|
return
|
|
}
|
|
|
|
form := web.GetForm[*forms.RegisterForm](ctx)
|
|
|
|
// Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
|
|
if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if ctx.HasError() {
|
|
ctx.HTML(http.StatusOK, tplSignUp)
|
|
return
|
|
}
|
|
|
|
context.VerifyCaptcha(ctx, tplSignUp, form)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !form.IsEmailDomainAllowed() {
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
|
|
return
|
|
}
|
|
|
|
if form.Password != form.Retype {
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplSignUp, &form)
|
|
return
|
|
}
|
|
if len(form.Password) < setting.MinPasswordLength {
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
|
|
return
|
|
}
|
|
if !password.IsComplexEnough(form.Password) {
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplSignUp, &form)
|
|
return
|
|
}
|
|
if err := password.IsPwned(ctx, form.Password); err != nil {
|
|
errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")
|
|
if password.IsErrIsPwnedRequest(err) {
|
|
log.Error(err.Error())
|
|
errMsg = ctx.Tr("auth.password_pwned_err")
|
|
}
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErrDeprecated(errMsg, tplSignUp, &form)
|
|
return
|
|
}
|
|
|
|
u := &user_model.User{
|
|
Name: form.UserName,
|
|
Email: form.Email,
|
|
Passwd: form.Password,
|
|
}
|
|
|
|
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) {
|
|
// error already handled
|
|
return
|
|
}
|
|
|
|
ctx.Flash.Success(ctx.Tr("auth.sign_up_successful"))
|
|
handleSignIn(ctx, u, false)
|
|
}
|
|
|
|
// createAndHandleCreatedUser calls createUserInContext and
|
|
// then handleUserCreated.
|
|
func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
|
|
if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
|
|
return false
|
|
}
|
|
return handleUserCreated(ctx, u, possibleLinkAccountData)
|
|
}
|
|
|
|
// createUserInContext creates a user and handles errors within a given context.
|
|
// Optionally, a template can be specified.
|
|
func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
|
|
meta := &user_model.Meta{
|
|
InitialIP: ctx.RemoteAddr(),
|
|
InitialUserAgent: ctx.Req.UserAgent(),
|
|
}
|
|
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
|
|
if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
|
|
switch setting.OAuth2Client.AccountLinking {
|
|
case setting.OAuth2AccountLinkingAuto:
|
|
user, err := user_model.GetIndividualUserByName(ctx, u.Name)
|
|
if err != nil {
|
|
if !user_model.IsErrUserNotExist(err) {
|
|
ctx.ServerError("GetIndividualUserByName", err)
|
|
return false
|
|
}
|
|
user, err = user_model.GetIndividualUserByPrimaryEmail(ctx, u.Email)
|
|
if err != nil {
|
|
if !user_model.IsErrUserNotExist(err) {
|
|
ctx.ServerError("GetIndividualUserByPrimaryEmail", err)
|
|
} else {
|
|
ctx.NotFound(err)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
if !oauth2IdentityMatchesUser(&possibleLinkAccountData.GothUser, user) {
|
|
break // name or email came from the sign-up form, report the conflict instead of linking
|
|
}
|
|
// TODO: probably we should respect 'remember' user's choice...
|
|
oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
|
|
return false // user is already created here, all redirects are handled
|
|
case setting.OAuth2AccountLinkingLogin:
|
|
showLinkingLogin(ctx, possibleLinkAccountData.AuthSourceID, possibleLinkAccountData.GothUser)
|
|
return false // user will be created only after linking login
|
|
}
|
|
}
|
|
|
|
// handle error without a template
|
|
if len(tpl) == 0 {
|
|
ctx.ServerError("CreateUser", err)
|
|
return false
|
|
}
|
|
|
|
// handle error with template
|
|
var errNameReserved db.ErrNameReserved
|
|
var errNamePatternNotAllowed db.ErrNamePatternNotAllowed
|
|
var errNameCharsNotAllowed db.ErrNameCharsNotAllowed
|
|
var errEmailInvalid user_model.ErrEmailInvalid
|
|
switch {
|
|
case user_model.IsErrUserAlreadyExist(err):
|
|
ctx.Data["Err_UserName"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tpl, form)
|
|
case user_model.IsErrEmailAlreadyUsed(err):
|
|
ctx.Data["Err_Email"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tpl, form)
|
|
case errors.As(err, &errEmailInvalid):
|
|
ctx.Data["Err_Email"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
|
|
case errors.As(err, &errNameReserved):
|
|
ctx.Data["Err_UserName"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", errNameReserved.Name), tpl, form)
|
|
case errors.As(err, &errNamePatternNotAllowed):
|
|
ctx.Data["Err_UserName"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", errNamePatternNotAllowed.Pattern), tpl, form)
|
|
case errors.As(err, &errNameCharsNotAllowed):
|
|
ctx.Data["Err_UserName"] = true
|
|
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", errNameCharsNotAllowed.Name), tpl, form)
|
|
default:
|
|
ctx.ServerError("CreateUser", err)
|
|
}
|
|
return false
|
|
}
|
|
log.Trace("Account created: %s", u.Name)
|
|
return true
|
|
}
|
|
|
|
// handleUserCreated does additional steps after a new user is created.
|
|
// It auto-sets admin for the only user, updates the optional external user and
|
|
// sends a confirmation email if required.
|
|
func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAccountData *LinkAccountData) (ok bool) {
|
|
// Auto-set admin for the only user.
|
|
hasUsers, err := user_model.HasUsers(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("HasUsers", err)
|
|
return false
|
|
}
|
|
if hasUsers.HasOnlyOneUser {
|
|
// the only user is the one just created, will set it as admin
|
|
opts := &user_service.UpdateOptions{
|
|
IsActive: optional.Some(true),
|
|
IsAdmin: user_service.UpdateOptionFieldFromValue(true),
|
|
SetLastLogin: true,
|
|
}
|
|
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// update external user information
|
|
if possibleLinkAccountData != nil {
|
|
if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSourceID, u, possibleLinkAccountData.GothUser); err != nil {
|
|
log.Error("EnsureLinkExternalToUser failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// for active user or the first (admin) user, we don't need to send confirmation email
|
|
if u.IsActive || u.ID == 1 {
|
|
return true
|
|
}
|
|
|
|
if setting.Service.RegisterManualConfirm {
|
|
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only"))
|
|
return false
|
|
}
|
|
|
|
sendActivateEmail(ctx, u)
|
|
return false
|
|
}
|
|
|
|
func renderActivationPromptMessage(ctx *context.Context, msg template.HTML) {
|
|
ctx.Data["ActivationPromptMessage"] = msg
|
|
ctx.HTML(http.StatusOK, TplActivatePrompt)
|
|
}
|
|
|
|
func sendActivateEmail(ctx *context.Context, u *user_model.User) {
|
|
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
|
|
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
|
|
return
|
|
}
|
|
|
|
if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
|
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
|
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
|
|
return
|
|
}
|
|
|
|
mailer.SendActivateAccountMail(ctx.Locale, u)
|
|
|
|
activeCodeLives := timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
|
|
msgHTML := ctx.Locale.Tr("auth.confirmation_mail_sent_prompt_ex", u.Email, activeCodeLives)
|
|
renderActivationPromptMessage(ctx, msgHTML)
|
|
}
|
|
|
|
func renderActivationVerifyPassword(ctx *context.Context, code string) {
|
|
ctx.Data["ActivationCode"] = code
|
|
ctx.Data["NeedVerifyLocalPassword"] = true
|
|
ctx.HTML(http.StatusOK, TplActivate)
|
|
}
|
|
|
|
func renderActivationChangeEmail(ctx *context.Context) {
|
|
ctx.HTML(http.StatusOK, TplActivate)
|
|
}
|
|
|
|
// Activate render activate user page
|
|
func Activate(ctx *context.Context) {
|
|
code := ctx.FormString("code")
|
|
|
|
if code == "" {
|
|
if ctx.Doer == nil {
|
|
ctx.Redirect(setting.AppSubURL + "/user/login")
|
|
return
|
|
} else if ctx.Doer.IsActive {
|
|
ctx.Redirect(setting.AppSubURL + "/")
|
|
return
|
|
}
|
|
|
|
if setting.MailService == nil || !setting.Service.RegisterEmailConfirm {
|
|
renderActivationPromptMessage(ctx, ctx.Tr("auth.disable_register_mail"))
|
|
return
|
|
}
|
|
|
|
// Resend confirmation email. FIXME: ideally this should be in a POST request
|
|
sendActivateEmail(ctx, ctx.Doer)
|
|
return
|
|
}
|
|
|
|
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
|
|
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
|
|
if user == nil { // if code is wrong
|
|
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
|
|
return
|
|
}
|
|
|
|
// if account is local account, verify password
|
|
if user.LoginSource == 0 {
|
|
renderActivationVerifyPassword(ctx, code)
|
|
return
|
|
}
|
|
|
|
handleAccountActivation(ctx, user)
|
|
}
|
|
|
|
// ActivatePost handles account activation with password check
|
|
func ActivatePost(ctx *context.Context) {
|
|
code := ctx.FormString("code")
|
|
if ctx.Doer != nil && ctx.Doer.IsActive {
|
|
ctx.Redirect(setting.AppSubURL + "/user/activate") // it will redirect again to the correct page
|
|
return
|
|
}
|
|
|
|
if code == "" {
|
|
newEmail := strings.TrimSpace(ctx.FormString("change_email"))
|
|
if ctx.Doer != nil && newEmail != "" && !strings.EqualFold(ctx.Doer.Email, newEmail) {
|
|
if user_model.ValidateEmail(newEmail) != nil {
|
|
ctx.Flash.Error(ctx.Locale.Tr("form.email_invalid"), true)
|
|
renderActivationChangeEmail(ctx)
|
|
return
|
|
}
|
|
err := user_model.ChangeInactivePrimaryEmail(ctx, ctx.Doer.ID, ctx.Doer.Email, newEmail)
|
|
if err != nil {
|
|
ctx.Flash.Error(ctx.Locale.Tr("admin.emails.not_updated", newEmail), true)
|
|
renderActivationChangeEmail(ctx)
|
|
return
|
|
}
|
|
ctx.Doer.Email = newEmail
|
|
}
|
|
// FIXME: at the moment, GET request handles the "send confirmation email" action. But the old code does this redirect and then send a confirmation email.
|
|
ctx.Redirect(setting.AppSubURL + "/user/activate")
|
|
return
|
|
}
|
|
|
|
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
|
|
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
|
|
if user == nil { // if code is wrong
|
|
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
|
|
return
|
|
}
|
|
|
|
// if account is local account, verify password
|
|
if user.LoginSource == 0 {
|
|
password := ctx.FormString("password")
|
|
if password == "" {
|
|
renderActivationVerifyPassword(ctx, code)
|
|
return
|
|
}
|
|
if !user.ValidatePassword(password) {
|
|
ctx.Flash.Error(ctx.Locale.Tr("auth.invalid_password"), true)
|
|
renderActivationVerifyPassword(ctx, code)
|
|
return
|
|
}
|
|
}
|
|
|
|
handleAccountActivation(ctx, user)
|
|
}
|
|
|
|
func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
|
user.IsActive = true
|
|
var err error
|
|
if user.Rands, err = user_model.GetUserSalt(); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return
|
|
}
|
|
if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
ctx.NotFound(err)
|
|
} else {
|
|
ctx.ServerError("UpdateUser", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil {
|
|
log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err)
|
|
ctx.ServerError("ActivateUserEmail", err)
|
|
return
|
|
}
|
|
|
|
log.Trace("User activated: %s", user.Name)
|
|
|
|
if err := regenerateSession(ctx, map[string]any{session.KeyUID: user.ID}); err != nil {
|
|
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
|
|
ctx.ServerError("ActivateUserEmail", err)
|
|
return
|
|
}
|
|
|
|
if err := resetLocale(ctx, user); err != nil {
|
|
ctx.ServerError("resetLocale", err)
|
|
return
|
|
}
|
|
|
|
if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return
|
|
}
|
|
|
|
ctx.Flash.Success(ctx.Tr("auth.account_activated"))
|
|
redirectAfterAuth(ctx)
|
|
}
|
|
|
|
// ActivateEmail render the activate email page
|
|
func ActivateEmail(ctx *context.Context) {
|
|
code := ctx.FormString("code")
|
|
emailStr := ctx.FormString("email")
|
|
|
|
// Verify code.
|
|
if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil {
|
|
if err := user_model.ActivateEmail(ctx, email); err != nil {
|
|
ctx.ServerError("ActivateEmail", err)
|
|
return
|
|
}
|
|
|
|
log.Trace("Email activated: %s", email.Email)
|
|
ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
|
|
|
|
if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
|
|
log.Warn("GetUserByID: %d", email.UID)
|
|
} else {
|
|
// Allow user to validate more emails
|
|
_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
|
|
}
|
|
}
|
|
|
|
// FIXME: e-mail verification does not require the user to be logged in,
|
|
// so this could be redirecting to the login page.
|
|
// Should users be logged in automatically here? (consider 2FA requirements, etc.)
|
|
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
|
}
|
|
|
|
func regenerateSession(ctx *context.Context, updates map[string]any) error {
|
|
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
|
|
return fmt.Errorf("regenerate session: %w", err)
|
|
}
|
|
sess := ctx.Session
|
|
sessID := sess.ID()
|
|
for k, v := range updates {
|
|
if err := sess.Set(k, v); err != nil {
|
|
return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err)
|
|
}
|
|
}
|
|
if err := sess.Release(); err != nil {
|
|
return fmt.Errorf("store session[%s]: %w", sessID, err)
|
|
}
|
|
return nil
|
|
}
|