summaryrefslogtreecommitdiffstats
path: root/routers/web/auth
diff options
context:
space:
mode:
Diffstat (limited to 'routers/web/auth')
-rw-r--r--routers/web/auth/2fa.go163
-rw-r--r--routers/web/auth/auth.go879
-rw-r--r--routers/web/auth/auth_test.go43
-rw-r--r--routers/web/auth/linkaccount.go308
-rw-r--r--routers/web/auth/main_test.go14
-rw-r--r--routers/web/auth/oauth.go1427
-rw-r--r--routers/web/auth/oauth_test.go103
-rw-r--r--routers/web/auth/openid.go391
-rw-r--r--routers/web/auth/password.go325
-rw-r--r--routers/web/auth/webauthn.go177
10 files changed, 3830 insertions, 0 deletions
diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go
new file mode 100644
index 0000000..f93177b
--- /dev/null
+++ b/routers/web/auth/2fa.go
@@ -0,0 +1,163 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/externalaccount"
+ "code.gitea.io/gitea/services/forms"
+)
+
+var (
+ tplTwofa base.TplName = "user/auth/twofa"
+ tplTwofaScratch base.TplName = "user/auth/twofa_scratch"
+)
+
+// TwoFactor shows the user a two-factor authentication page.
+func TwoFactor(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa")
+
+ if CheckAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplTwofa)
+}
+
+// TwoFactorPost validates a user's two-factor authentication token.
+func TwoFactorPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
+ ctx.Data["Title"] = ctx.Tr("twofa")
+
+ // Ensure user is in a 2FA session.
+ idSess := ctx.Session.Get("twofaUid")
+ if idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ id := idSess.(int64)
+ twofa, err := auth.GetTwoFactorByUID(ctx, id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ // Validate the passcode with the stored TOTP secret.
+ ok, err := twofa.ValidateTOTP(form.Passcode)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if ok && twofa.LastUsedPasscode != form.Passcode {
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ u, err := user_model.GetUserByID(ctx, id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if ctx.Session.Get("linkAccount") != nil {
+ err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ }
+
+ twofa.LastUsedPasscode = form.Passcode
+ if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
+}
+
+// TwoFactorScratch shows the scratch code form for two-factor authentication.
+func TwoFactorScratch(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa_scratch")
+
+ if CheckAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplTwofaScratch)
+}
+
+// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token.
+func TwoFactorScratchPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm)
+ ctx.Data["Title"] = ctx.Tr("twofa_scratch")
+
+ // Ensure user is in a 2FA session.
+ idSess := ctx.Session.Get("twofaUid")
+ if idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ id := idSess.(int64)
+ twofa, err := auth.GetTwoFactorByUID(ctx, id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ // Validate the passcode with the stored TOTP secret.
+ if twofa.VerifyScratchToken(form.Token) {
+ // Invalidate the scratch token.
+ _, err = twofa.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ u, err := user_model.GetUserByID(ctx, id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignInFull(ctx, u, remember, false)
+ if ctx.Written() {
+ return
+ }
+ ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
+}
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
new file mode 100644
index 0000000..9649ddd
--- /dev/null
+++ b/routers/web/auth/auth.go
@@ -0,0 +1,879 @@
+// 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"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/eventsource"
+ "code.gitea.io/gitea/modules/httplib"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/session"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ auth_service "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/externalaccount"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+ notify_service "code.gitea.io/gitea/services/notify"
+ user_service "code.gitea.io/gitea/services/user"
+
+ "github.com/markbates/goth"
+)
+
+const (
+ // tplSignIn template for sign in page
+ tplSignIn base.TplName = "user/auth/signin"
+ // tplSignUp template path for sign up page
+ tplSignUp base.TplName = "user/auth/signup"
+ // TplActivate template path for activate user
+ TplActivate base.TplName = "user/auth/activate"
+)
+
+// autoSignIn reads cookie and try to auto-login.
+func autoSignIn(ctx *context.Context) (bool, error) {
+ isSucceed := false
+ defer func() {
+ if !isSucceed {
+ ctx.DeleteSiteCookie(setting.CookieRememberName)
+ }
+ }()
+
+ authCookie := ctx.GetSiteCookie(setting.CookieRememberName)
+ if len(authCookie) == 0 {
+ return false, nil
+ }
+
+ u, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization, false)
+ if err != nil {
+ return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err)
+ }
+ if u == nil {
+ return false, nil
+ }
+
+ isSucceed = true
+
+ if err := updateSession(ctx, nil, map[string]any{
+ // Set session IDs
+ "uid": u.ID,
+ }); err != nil {
+ return false, fmt.Errorf("unable to updateSession: %w", err)
+ }
+
+ if err := resetLocale(ctx, u); err != nil {
+ return false, err
+ }
+
+ ctx.Csrf.DeleteCookie(ctx)
+ 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 RedirectAfterLogin(ctx *context.Context) {
+ redirectTo := ctx.FormString("redirect_to")
+ if redirectTo == "" {
+ redirectTo = ctx.GetSiteCookie("redirect_to")
+ }
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ nextRedirectTo := setting.AppSubURL + string(setting.LandingPageURL)
+ if setting.LandingPageURL == setting.LandingPageLogin {
+ nextRedirectTo = setting.AppSubURL + "/" // do not cycle-redirect to the login page
+ }
+ ctx.RedirectToFirst(redirectTo, nextRedirectTo)
+}
+
+func CheckAutoLogin(ctx *context.Context) bool {
+ isSucceed, err := autoSignIn(ctx) // try to auto-login
+ if err != nil {
+ ctx.ServerError("autoSignIn", err)
+ return true
+ }
+
+ redirectTo := ctx.FormString("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ }
+
+ if isSucceed {
+ RedirectAfterLogin(ctx)
+ return true
+ }
+
+ return false
+}
+
+// SignIn render sign in page
+func SignIn(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ if CheckAutoLogin(ctx) {
+ return
+ }
+
+ if ctx.IsSigned {
+ RedirectAfterLogin(ctx)
+ return
+ }
+
+ oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ ctx.Data["OAuth2Providers"] = 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"] = auth.IsSSPIEnabled(ctx)
+
+ if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
+ context.SetCaptchaData(ctx)
+ }
+
+ ctx.HTML(http.StatusOK, tplSignIn)
+}
+
+// SignInPost response for sign in request
+func SignInPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ ctx.Data["OAuth2Providers"] = 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"] = auth.IsSSPIEnabled(ctx)
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSignIn)
+ return
+ }
+
+ form := web.GetForm(ctx).(*forms.SignInForm)
+
+ if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
+ context.SetCaptchaData(ctx)
+
+ 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.RenderWithErr(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.RenderWithErr(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 if user_model.IsErrUserInactive(err) {
+ if setting.Service.RegisterEmailConfirm {
+ ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
+ ctx.HTML(http.StatusOK, TplActivate)
+ } else {
+ 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 skipper, ok := source.Cfg.(auth_service.LocalTwoFASkipper); ok && skipper.IsSkipLocalTwoFA() {
+ 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.
+ hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ // Check if the user has webauthn registration
+ hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if !hasTOTPtwofa && !hasWebAuthnTwofa {
+ // No two factor auth configured we can sign in the user
+ handleSignIn(ctx, u, form.Remember)
+ return
+ }
+
+ updates := map[string]any{
+ // User will need to use 2FA TOTP or WebAuthn, save data
+ "twofaUid": u.ID,
+ "twofaRemember": form.Remember,
+ }
+ if hasTOTPtwofa {
+ // User will need to use WebAuthn, save data
+ updates["totpEnrolled"] = u.ID
+ }
+ if err := updateSession(ctx, nil, updates); err != nil {
+ ctx.ServerError("UserSignIn: Unable to update session", err)
+ return
+ }
+
+ // If we have WebAuthn redirect there first
+ if hasWebAuthnTwofa {
+ ctx.Redirect(setting.AppSubURL + "/user/webauthn")
+ return
+ }
+
+ // Fallback to 2FA
+ 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) {
+ redirect := handleSignInFull(ctx, u, remember, true)
+ if ctx.Written() {
+ return
+ }
+ ctx.Redirect(redirect)
+}
+
+func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
+ if remember {
+ if err := ctx.SetLTACookie(u); err != nil {
+ ctx.ServerError("GenerateAuthToken", err)
+ return setting.AppSubURL + "/"
+ }
+ }
+
+ if err := updateSession(ctx, []string{
+ // Delete the openid, 2fa and linkaccount data
+ "openid_verified_uri",
+ "openid_signin_remember",
+ "openid_determined_email",
+ "openid_determined_username",
+ "twofaUid",
+ "twofaRemember",
+ "linkAccount",
+ }, map[string]any{
+ "uid": u.ID,
+ }); err != nil {
+ ctx.ServerError("RegenerateSession", err)
+ return setting.AppSubURL + "/"
+ }
+
+ // 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 setting.AppSubURL + "/"
+ }
+ }
+
+ middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
+
+ if ctx.Locale.Language() != u.Language {
+ ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
+ }
+
+ // Clear whatever CSRF cookie has right now, force to generate a new one
+ ctx.Csrf.DeleteCookie(ctx)
+
+ // Register last login
+ if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return setting.AppSubURL + "/"
+ }
+
+ redirectTo := ctx.GetSiteCookie("redirect_to")
+ if redirectTo != "" {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ }
+ if obeyRedirect {
+ return ctx.RedirectToFirst(redirectTo)
+ }
+ if !httplib.IsRiskyRedirectURL(redirectTo) {
+ return redirectTo
+ }
+ return setting.AppSubURL + "/"
+}
+
+func getUserName(gothUser *goth.User) (string, error) {
+ switch setting.OAuth2Client.Username {
+ case setting.OAuth2UsernameEmail:
+ return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0])
+ case setting.OAuth2UsernameNickname:
+ return user_model.NormalizeUserName(gothUser.NickName)
+ default: // OAuth2UsernameUserid
+ return gothUser.UserID, nil
+ }
+}
+
+// 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)
+ ctx.Csrf.DeleteCookie(ctx)
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+}
+
+// SignOut sign out from login status
+func SignOut(ctx *context.Context) {
+ if ctx.Doer != nil {
+ eventsource.GetManager().SendMessage(ctx.Doer.ID, &eventsource.Event{
+ Name: "logout",
+ Data: ctx.Session.ID(),
+ })
+ }
+ HandleSignOut(ctx)
+ ctx.JSONRedirect(setting.AppSubURL + "/")
+}
+
+// SignUp render the register page
+func SignUp(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_up")
+
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
+
+ oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
+ if err != nil {
+ ctx.ServerError("UserSignUp", err)
+ return
+ }
+
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+ context.SetCaptchaData(ctx)
+
+ ctx.Data["PageIsSignUp"] = true
+
+ // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
+
+ redirectTo := ctx.FormString("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ }
+
+ ctx.HTML(http.StatusOK, tplSignUp)
+}
+
+// SignUpPost response for sign up information submission
+func SignUpPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RegisterForm)
+ ctx.Data["Title"] = ctx.Tr("sign_up")
+
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
+
+ oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
+ if err != nil {
+ ctx.ServerError("UserSignUp", err)
+ return
+ }
+
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+ context.SetCaptchaData(ctx)
+
+ ctx.Data["PageIsSignUp"] = true
+
+ // Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
+ if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
+ ctx.Error(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.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
+ return
+ }
+
+ if form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplSignUp, &form)
+ return
+ }
+ if len(form.Password) < setting.MinPasswordLength {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
+ return
+ }
+ if !password.IsComplexEnough(form.Password) {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(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.RenderWithErr(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, false) {
+ // 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 base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool {
+ if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) {
+ return false
+ }
+ return handleUserCreated(ctx, u, gothUser)
+}
+
+// createUserInContext creates a user and handles errors within a given context.
+// Optionally a template can be specified.
+func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) {
+ if err := user_model.CreateUser(ctx, u, overwrites); err != nil {
+ if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
+ if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto {
+ var user *user_model.User
+ user = &user_model.User{Name: u.Name}
+ hasUser, err := user_model.GetUser(ctx, user)
+ if !hasUser || err != nil {
+ user = &user_model.User{Email: u.Email}
+ hasUser, err = user_model.GetUser(ctx, user)
+ if !hasUser || err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return false
+ }
+ }
+
+ // TODO: probably we should respect 'remember' user's choice...
+ linkAccount(ctx, user, *gothUser, true)
+ return false // user is already created here, all redirects are handled
+ } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin {
+ showLinkingLogin(ctx, *gothUser)
+ return false // user will be created only after linking login
+ }
+ }
+
+ // handle error without template
+ if len(tpl) == 0 {
+ ctx.ServerError("CreateUser", err)
+ return false
+ }
+
+ // handle error with template
+ switch {
+ case user_model.IsErrUserAlreadyExist(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form)
+ case user_model.IsErrEmailAlreadyUsed(err):
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form)
+ case user_model.IsErrEmailCharIsNotSupported(err):
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form)
+ case user_model.IsErrEmailInvalid(err):
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form)
+ case db.IsErrNameReserved(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form)
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form)
+ case db.IsErrNameCharsNotAllowed(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(db.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, gothUser *goth.User) (ok bool) {
+ // Auto-set admin for the only user.
+ if user_model.CountUsers(ctx, nil) == 1 {
+ opts := &user_service.UpdateOptions{
+ IsActive: optional.Some(true),
+ IsAdmin: optional.Some(true),
+ SetLastLogin: true,
+ }
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return false
+ }
+ }
+
+ notify_service.NewUserSignUp(ctx, u)
+ // update external user information
+ if gothUser != nil {
+ if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil {
+ if !errors.Is(err, util.ErrNotExist) {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+ }
+ }
+
+ // Send confirmation email
+ if !u.IsActive && u.ID > 1 {
+ if setting.Service.RegisterManualConfirm {
+ ctx.Data["ManualActivationOnly"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return false
+ }
+
+ if err := mailer.SendActivateAccountMail(ctx, u); err != nil {
+ ctx.ServerError("SendActivateAccountMail", err)
+ return false
+ }
+
+ ctx.Data["IsSendRegisterMail"] = true
+ ctx.Data["Email"] = u.Email
+ ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
+ ctx.HTML(http.StatusOK, TplActivate)
+
+ if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ return false
+ }
+
+ return true
+}
+
+// Activate render activate user page
+func Activate(ctx *context.Context) {
+ code := ctx.FormString("code")
+
+ if len(code) == 0 {
+ ctx.Data["IsActivatePage"] = true
+ if ctx.Doer == nil || ctx.Doer.IsActive {
+ ctx.NotFound("invalid user", nil)
+ return
+ }
+ // Resend confirmation email.
+ if setting.Service.RegisterEmailConfirm {
+ var cacheKey string
+ if ctx.Cache.IsExist("MailChangedJustNow_" + ctx.Doer.LowerName) {
+ cacheKey = "MailChangedLimit_"
+ if err := ctx.Cache.Delete("MailChangedJustNow_" + ctx.Doer.LowerName); err != nil {
+ log.Error("Delete cache(MailChangedJustNow) fail: %v", err)
+ }
+ } else {
+ cacheKey = "MailResendLimit_"
+ }
+ if ctx.Cache.IsExist(cacheKey + ctx.Doer.LowerName) {
+ ctx.Data["ResendLimited"] = true
+ } else {
+ ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
+ if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil {
+ ctx.ServerError("SendActivateAccountMail", err)
+ return
+ }
+
+ if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ }
+ } else {
+ ctx.Data["ServiceNotEnabled"] = true
+ }
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, false)
+ if err != nil {
+ ctx.ServerError("VerifyUserAuthorizationToken", err)
+ return
+ }
+
+ // if code is wrong
+ if user == nil {
+ ctx.Data["IsCodeInvalid"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ // if account is local account, verify password
+ if user.LoginSource == 0 {
+ ctx.Data["Code"] = code
+ ctx.Data["NeedsPassword"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ handleAccountActivation(ctx, user)
+}
+
+// ActivatePost handles account activation with password check
+func ActivatePost(ctx *context.Context) {
+ code := ctx.FormString("code")
+ if len(code) == 0 {
+ email := ctx.FormString("email")
+ if len(email) > 0 {
+ ctx.Data["IsActivatePage"] = true
+ if ctx.Doer == nil || ctx.Doer.IsActive {
+ ctx.NotFound("invalid user", nil)
+ return
+ }
+ // Change the primary email
+ if setting.Service.RegisterEmailConfirm {
+ if ctx.Cache.IsExist("MailChangeLimit_" + ctx.Doer.LowerName) {
+ ctx.Data["ResendLimited"] = true
+ } else {
+ ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
+ err := user_service.ReplaceInactivePrimaryEmail(ctx, ctx.Doer.Email, &user_model.EmailAddress{
+ UID: ctx.Doer.ID,
+ Email: email,
+ })
+ if err != nil {
+ ctx.Data["IsActivatePage"] = false
+ log.Error("Couldn't replace inactive primary email of user %d: %v", ctx.Doer.ID, err)
+ ctx.RenderWithErr(ctx.Tr("auth.change_unconfirmed_email_error", err), TplActivate, nil)
+ return
+ }
+ if err := ctx.Cache.Put("MailChangeLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailChangeLimit) fail: %v", err)
+ }
+ if err := ctx.Cache.Put("MailChangedJustNow_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailChangedJustNow) fail: %v", err)
+ }
+
+ // Confirmation mail will be re-sent after the redirect to `/user/activate` below.
+ }
+ } else {
+ ctx.Data["ServiceNotEnabled"] = true
+ }
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/activate")
+ return
+ }
+
+ user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, true)
+ if err != nil {
+ ctx.ServerError("VerifyUserAuthorizationToken", err)
+ return
+ }
+
+ // if code is wrong
+ if user == nil {
+ ctx.Data["IsCodeInvalid"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ // if account is local account, verify password
+ if user.LoginSource == 0 {
+ password := ctx.FormString("password")
+ if len(password) == 0 {
+ ctx.Data["Code"] = code
+ ctx.Data["NeedsPassword"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+ if !user.ValidatePassword(password) {
+ ctx.Data["IsPasswordInvalid"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ 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("UpdateUserCols", 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 := updateSession(ctx, nil, map[string]any{
+ "uid": 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"))
+ if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+}
+
+// ActivateEmail render the activate email page
+func ActivateEmail(ctx *context.Context) {
+ code := ctx.FormString("code")
+ emailStr := ctx.FormString("email")
+
+ u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr), true)
+ if err != nil {
+ ctx.ServerError("VerifyUserAuthorizationToken", err)
+ return
+ }
+ if u == nil {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
+ if err != nil {
+ ctx.ServerError("GetEmailAddressOfUser", err)
+ return
+ }
+
+ 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"))
+
+ // 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 updateSession(ctx *context.Context, deletes []string, 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 := range deletes {
+ if err := sess.Delete(k); err != nil {
+ return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err)
+ }
+ }
+ 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
+}
diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go
new file mode 100644
index 0000000..c6afbf8
--- /dev/null
+++ b/routers/web/auth/auth_test.go
@@ -0,0 +1,43 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserLogin(t *testing.T) {
+ ctx, resp := contexttest.MockContext(t, "/user/login")
+ SignIn(ctx)
+ assert.Equal(t, http.StatusOK, resp.Code)
+
+ ctx, resp = contexttest.MockContext(t, "/user/login")
+ ctx.IsSigned = true
+ SignIn(ctx)
+ assert.Equal(t, http.StatusSeeOther, resp.Code)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+
+ ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to=/other")
+ ctx.IsSigned = true
+ SignIn(ctx)
+ assert.Equal(t, "/other", test.RedirectURL(resp))
+
+ ctx, resp = contexttest.MockContext(t, "/user/login")
+ ctx.Req.AddCookie(&http.Cookie{Name: "redirect_to", Value: "/other-cookie"})
+ ctx.IsSigned = true
+ SignIn(ctx)
+ assert.Equal(t, "/other-cookie", test.RedirectURL(resp))
+
+ ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to="+url.QueryEscape("https://example.com"))
+ ctx.IsSigned = true
+ SignIn(ctx)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+}
diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go
new file mode 100644
index 0000000..9b0141c
--- /dev/null
+++ b/routers/web/auth/linkaccount.go
@@ -0,0 +1,308 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ auth_service "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/externalaccount"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/markbates/goth"
+)
+
+var tplLinkAccount base.TplName = "user/auth/link_account"
+
+// LinkAccount shows the page where the user can decide to login or create a new account
+func LinkAccount(ctx *context.Context) {
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
+ ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
+ ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ gu, _ := gothUser.(goth.User)
+ uname, err := getUserName(&gu)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ email := gu.Email
+ ctx.Data["user_name"] = uname
+ ctx.Data["email"] = email
+
+ if len(email) != 0 {
+ u, err := user_model.GetUserByEmail(ctx, email)
+ if err != nil && !user_model.IsErrUserNotExist(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if u != nil {
+ ctx.Data["user_exists"] = true
+ }
+ } else if len(uname) != 0 {
+ u, err := user_model.GetUserByName(ctx, uname)
+ if err != nil && !user_model.IsErrUserNotExist(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if u != nil {
+ ctx.Data["user_exists"] = true
+ }
+ }
+
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+}
+
+func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl base.TplName, invoker string, err error) {
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
+ } else if errors.Is(err, util.ErrInvalidArgument) {
+ ctx.Data["user_exists"] = true
+ ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
+ } else if user_model.IsErrUserProhibitLogin(err) {
+ ctx.Data["user_exists"] = true
+ log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ } else if user_model.IsErrUserInactive(err) {
+ ctx.Data["user_exists"] = true
+ if setting.Service.RegisterEmailConfirm {
+ ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
+ ctx.HTML(http.StatusOK, TplActivate)
+ } else {
+ log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ }
+ } else {
+ ctx.ServerError(invoker, err)
+ }
+}
+
+// LinkAccountPostSignIn handle the coupling of external account with another account using signIn
+func LinkAccountPostSignIn(ctx *context.Context) {
+ signInForm := web.GetForm(ctx).(*forms.SignInForm)
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["LinkAccountModeSignIn"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ 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"] = setting.Service.McaptchaURL
+ ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+ return
+ }
+
+ u, _, err := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password)
+ if err != nil {
+ handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err)
+ return
+ }
+
+ linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember)
+}
+
+func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) {
+ updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+
+ // If this user is enrolled in 2FA, we can't sign the user in just yet.
+ // Instead, redirect them to the 2FA authentication page.
+ // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
+ _, err := auth.GetTwoFactorByUID(ctx, u.ID)
+ if err != nil {
+ if !auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ err = externalaccount.LinkAccountToUser(ctx, u, gothUser)
+ if err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ if err := updateSession(ctx, nil, map[string]any{
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ "twofaUid": u.ID,
+ "twofaRemember": remember,
+ "linkAccount": true,
+ }); err != nil {
+ ctx.ServerError("RegenerateSession", err)
+ return
+ }
+
+ // If WebAuthn is enrolled -> Redirect to WebAuthn instead
+ regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/webauthn")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
+func LinkAccountPostRegister(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RegisterForm)
+ // TODO Make insecure passwords optional for local accounts also,
+ // once email-based Second-Factor Auth is available
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["LinkAccountModeRegister"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ 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"] = setting.Service.McaptchaURL
+ ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUserInterface := ctx.Session.Get("linkAccountGothUser")
+ if gothUserInterface == nil {
+ ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
+ return
+ }
+ gothUser, ok := gothUserInterface.(goth.User)
+ if !ok {
+ ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface))
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+ return
+ }
+
+ if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
+ context.VerifyCaptcha(ctx, tplLinkAccount, form)
+ if ctx.Written() {
+ return
+ }
+ }
+
+ if !form.IsEmailDomainAllowed() {
+ ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form)
+ return
+ }
+
+ if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword {
+ // In user_model.User an empty password is classed as not set, so we set form.Password to empty.
+ // Eventually the database should be changed to indicate "Second Factor"-enabled accounts
+ // (accounts that do not introduce the security vulnerabilities of a password).
+ // If a user decides to circumvent second-factor security, and purposefully create a password,
+ // they can still do so using the "Recover Account" option.
+ form.Password = ""
+ } else {
+ if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form)
+ return
+ }
+ if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
+ return
+ }
+ }
+
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
+ if err != nil {
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+
+ u := &user_model.User{
+ Name: form.UserName,
+ Email: form.Email,
+ Passwd: form.Password,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) {
+ // error already handled
+ return
+ }
+
+ source := authSource.Cfg.(*oauth2.Source)
+ if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+
+ handleSignIn(ctx, u, false)
+}
diff --git a/routers/web/auth/main_test.go b/routers/web/auth/main_test.go
new file mode 100644
index 0000000..b438e5d
--- /dev/null
+++ b/routers/web/auth/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
new file mode 100644
index 0000000..8705d1b
--- /dev/null
+++ b/routers/web/auth/oauth.go
@@ -0,0 +1,1427 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ go_context "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ org_model "code.gitea.io/gitea/models/organization"
+ user_model "code.gitea.io/gitea/models/user"
+ auth_module "code.gitea.io/gitea/modules/auth"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ auth_service "code.gitea.io/gitea/services/auth"
+ source_service "code.gitea.io/gitea/services/auth/source"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/externalaccount"
+ "code.gitea.io/gitea/services/forms"
+ remote_service "code.gitea.io/gitea/services/remote"
+ user_service "code.gitea.io/gitea/services/user"
+
+ "gitea.com/go-chi/binding"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
+ "github.com/markbates/goth/providers/fitbit"
+ "github.com/markbates/goth/providers/openidConnect"
+ "github.com/markbates/goth/providers/zoom"
+ go_oauth2 "golang.org/x/oauth2"
+)
+
+const (
+ tplGrantAccess base.TplName = "user/auth/grant"
+ tplGrantError base.TplName = "user/auth/grant_error"
+)
+
+// TODO move error and responses to SDK or models
+
+// AuthorizeErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeErrorCode string
+
+const (
+ // ErrorCodeInvalidRequest represents the according error in RFC 6749
+ ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
+ // ErrorCodeUnauthorizedClient represents the according error in RFC 6749
+ ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
+ // ErrorCodeAccessDenied represents the according error in RFC 6749
+ ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
+ // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
+ ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
+ // ErrorCodeInvalidScope represents the according error in RFC 6749
+ ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
+ // ErrorCodeServerError represents the according error in RFC 6749
+ ErrorCodeServerError AuthorizeErrorCode = "server_error"
+ // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
+ ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
+)
+
+// AuthorizeError represents an error type specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeError struct {
+ ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
+ ErrorDescription string
+ State string
+}
+
+// Error returns the error message
+func (err AuthorizeError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// AccessTokenErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenErrorCode string
+
+const (
+ // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
+ // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidClient = "invalid_client"
+ // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidGrant = "invalid_grant"
+ // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
+ // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
+ // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidScope = "invalid_scope"
+)
+
+// AccessTokenError represents an error response specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenError struct {
+ ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+// Error returns the error message
+func (err AccessTokenError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// errCallback represents a oauth2 callback error
+type errCallback struct {
+ Code string
+ Description string
+}
+
+func (err errCallback) Error() string {
+ return err.Description
+}
+
+// TokenType specifies the kind of token
+type TokenType string
+
+const (
+ // TokenTypeBearer represents a token type specified in RFC 6749
+ TokenTypeBearer TokenType = "bearer"
+ // TokenTypeMAC represents a token type specified in RFC 6749
+ TokenTypeMAC = "mac"
+)
+
+// AccessTokenResponse represents a successful access token response
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
+type AccessTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType TokenType `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+}
+
+func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
+ if setting.OAuth2.InvalidateRefreshTokens {
+ if err := grant.IncreaseCounter(ctx); err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "cannot increase the grant counter",
+ }
+ }
+ }
+ // generate access token to access the API
+ expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
+ accessToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Type: oauth2.TypeAccessToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ },
+ }
+ signedAccessToken, err := accessToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate refresh token to request an access token after it expired later
+ refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
+ refreshToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Counter: grant.Counter,
+ Type: oauth2.TypeRefreshToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
+ },
+ }
+ signedRefreshToken, err := refreshToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate OpenID Connect id_token
+ signedIDToken := ""
+ if grant.ScopeContains("openid") {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find application",
+ }
+ }
+ user, err := user_model.GetUserByID(ctx, grant.UserID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find user",
+ }
+ }
+ log.Error("Error loading user: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+
+ idToken := &oauth2.OIDCToken{
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ Issuer: setting.AppURL,
+ Audience: []string{app.ClientID},
+ Subject: fmt.Sprint(grant.UserID),
+ },
+ Nonce: grant.Nonce,
+ }
+ if grant.ScopeContains("profile") {
+ idToken.Name = user.GetDisplayName()
+ idToken.PreferredUsername = user.Name
+ idToken.Profile = user.HTMLURL()
+ idToken.Picture = user.AvatarLink(ctx)
+ idToken.Website = user.Website
+ idToken.Locale = user.Language
+ idToken.UpdatedAt = user.UpdatedUnix
+ }
+ if grant.ScopeContains("email") {
+ idToken.Email = user.Email
+ idToken.EmailVerified = user.IsActive
+ }
+ if grant.ScopeContains("groups") {
+ onlyPublicGroups := ifOnlyPublicGroups(grant.Scope)
+
+ groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups)
+ if err != nil {
+ log.Error("Error getting groups: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+ idToken.Groups = groups
+ }
+
+ signedIDToken, err = idToken.SignToken(clientKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+ }
+
+ return &AccessTokenResponse{
+ AccessToken: signedAccessToken,
+ TokenType: TokenTypeBearer,
+ ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
+ RefreshToken: signedRefreshToken,
+ IDToken: signedIDToken,
+ }, nil
+}
+
+type userInfoResponse struct {
+ Sub string `json:"sub"`
+ Name string `json:"name"`
+ Username string `json:"preferred_username"`
+ Email string `json:"email"`
+ Picture string `json:"picture"`
+ Groups []string `json:"groups,omitempty"`
+}
+
+func ifOnlyPublicGroups(scopes string) bool {
+ scopes = strings.ReplaceAll(scopes, ",", " ")
+ scopesList := strings.Fields(scopes)
+ for _, scope := range scopesList {
+ if scope == "all" || scope == "read:organization" || scope == "read:admin" {
+ return false
+ }
+ }
+ return true
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+ if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ response := &userInfoResponse{
+ Sub: fmt.Sprint(ctx.Doer.ID),
+ Name: ctx.Doer.FullName,
+ Username: ctx.Doer.Name,
+ Email: ctx.Doer.Email,
+ Picture: ctx.Doer.AvatarLink(ctx),
+ }
+
+ var token string
+ if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
+ auths := strings.Fields(auHead)
+ if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+ token = auths[1]
+ }
+ }
+
+ _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
+ onlyPublicGroups := ifOnlyPublicGroups(grantScopes)
+
+ groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
+ if err != nil {
+ ctx.ServerError("Oauth groups for user", err)
+ return
+ }
+ response.Groups = groups
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// returns a list of "org" and "org:team" strings,
+// that the given user is a part of.
+func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
+ orgs, err := org_model.GetUserOrgsList(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("GetUserOrgList: %w", err)
+ }
+
+ var groups []string
+ for _, org := range orgs {
+ if setting.OAuth2.EnableAdditionalGrantScopes {
+ if onlyPublicGroups {
+ public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID)
+ if !public && err == nil {
+ continue
+ }
+ }
+ }
+
+ groups = append(groups, org.Name)
+ teams, err := org.LoadTeams(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("LoadTeams: %w", err)
+ }
+ for _, team := range teams {
+ if team.IsMember(ctx, user.ID) {
+ groups = append(groups, org.Name+":"+team.LowerName)
+ }
+ }
+ }
+ return groups, nil
+}
+
+func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ return base.BasicAuthDecode(authData)
+ }
+ return "", "", errors.New("invalid basic authentication")
+}
+
+// IntrospectOAuth introspects an oauth token
+func IntrospectOAuth(ctx *context.Context) {
+ clientIDValid := false
+ if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
+ if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
+ // this is likely a database error; log it and respond without details
+ log.Error("Error retrieving client_id: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+ clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
+ }
+ if !clientIDValid {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ var response struct {
+ Active bool `json:"active"`
+ Scope string `json:"scope,omitempty"`
+ Username string `json:"username,omitempty"`
+ jwt.RegisteredClaims
+ }
+
+ form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
+ token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
+ if err == nil {
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err == nil && grant != nil {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err == nil && app != nil {
+ response.Active = true
+ response.Scope = grant.Scope
+ response.Issuer = setting.AppURL
+ response.Audience = []string{app.ClientID}
+ response.Subject = fmt.Sprint(grant.UserID)
+ }
+ if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
+ response.Username = user.Name
+ }
+ }
+ }
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// AuthorizeOAuth manages authorize requests
+func AuthorizeOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AuthorizationForm)
+ errs := binding.Errors{}
+ errs = form.Validate(ctx.Req, errs)
+ if len(errs) > 0 {
+ errstring := ""
+ for _, e := range errs {
+ errstring += e.Error() + "\n"
+ }
+ ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ if auth.IsErrOauthClientIDInvalid(err) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnauthorizedClient,
+ ErrorDescription: "Client ID not registered",
+ State: form.State,
+ }, "")
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+
+ var user *user_model.User
+ if app.UID != 0 {
+ user, err = user_model.GetUserByID(ctx, app.UID)
+ if err != nil {
+ ctx.ServerError("GetUserByID", err)
+ return
+ }
+ }
+
+ if !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "Unregistered Redirect URI",
+ State: form.State,
+ }, "")
+ return
+ }
+
+ if form.ResponseType != "code" {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnsupportedResponseType,
+ ErrorDescription: "Only code response type is supported.",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ // pkce support
+ switch form.CodeChallengeMethod {
+ case "S256":
+ case "plain":
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ case "":
+ // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
+ // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
+ if !app.ConfidentialClient {
+ // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
+ // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "PKCE is required for public clients",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ default:
+ // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
+ // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "unsupported code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+
+ // Redirect if user already granted access and the application is confidential.
+ // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2
+ if app.ConfidentialClient && grant != nil {
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ // Update nonce to reflect the new session
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+ ctx.Redirect(redirect.String())
+ return
+ }
+
+ // show authorize page to grant access
+ ctx.Data["Application"] = app
+ ctx.Data["RedirectURI"] = form.RedirectURI
+ ctx.Data["State"] = form.State
+ ctx.Data["Scope"] = form.Scope
+ ctx.Data["Nonce"] = form.Nonce
+ if user != nil {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
+ } else {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
+ }
+ ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
+ // TODO document SESSION <=> FORM
+ err = ctx.Session.Set("client_id", app.ClientID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("redirect_uri", form.RedirectURI)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("state", form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ ctx.HTML(http.StatusOK, tplGrantAccess)
+}
+
+// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
+func GrantApplicationOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.GrantApplicationForm)
+ if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
+ ctx.Session.Get("redirect_uri") != form.RedirectURI {
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+
+ if !form.Granted {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "the request is denied",
+ ErrorCode: ErrorCodeAccessDenied,
+ }, form.RedirectURI)
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ if grant == nil {
+ grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
+ if err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "cannot create grant for user",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+ } else if grant.Scope != form.Scope {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "a grant exists with different scope",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+
+ var codeChallenge, codeChallengeMethod string
+ codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
+ codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
+
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
+func OIDCWellKnown(ctx *context.Context) {
+ ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
+ ctx.JSONTemplate("user/auth/oidc_wellknown")
+}
+
+// OIDCKeys generates the JSON Web Key Set
+func OIDCKeys(ctx *context.Context) {
+ jwk, err := oauth2.DefaultSigningKey.ToJWK()
+ if err != nil {
+ log.Error("Error converting signing key to JWK: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+
+ jwk["use"] = "sig"
+
+ jwks := map[string][]map[string]string{
+ "keys": {
+ jwk,
+ },
+ }
+
+ ctx.Resp.Header().Set("Content-Type", "application/json")
+ enc := json.NewEncoder(ctx.Resp)
+ if err := enc.Encode(jwks); err != nil {
+ log.Error("Failed to encode representation as json. Error: %v", err)
+ }
+}
+
+// AccessTokenOAuth manages all access token requests by the client
+func AccessTokenOAuth(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AccessTokenForm)
+ // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
+ if form.ClientID == "" || form.ClientSecret == "" {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ clientID, clientSecret, err := base.BasicAuthDecode(authData)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot parse basic auth header",
+ })
+ return
+ }
+ // validate that any fields present in the form match the Basic auth header
+ if form.ClientID != "" && form.ClientID != clientID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_id in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientID = clientID
+ if form.ClientSecret != "" && form.ClientSecret != clientSecret {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_secret in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientSecret = clientSecret
+ }
+ }
+
+ serverKey := oauth2.DefaultSigningKey
+ clientKey := serverKey
+ if serverKey.IsSymmetric() {
+ var err error
+ clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "Error creating signing key",
+ })
+ return
+ }
+ }
+
+ switch form.GrantType {
+ case "refresh_token":
+ handleRefreshToken(ctx, form, serverKey, clientKey)
+ case "authorization_code":
+ handleAuthorizationCode(ctx, form, serverKey, clientKey)
+ default:
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
+ ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
+ })
+ }
+}
+
+func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
+ })
+ return
+ }
+ // "The authorization server MUST ... require client authentication for confidential clients"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-6
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ // "invalid_client ... Client authentication failed"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+
+ token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unable to parse refresh token",
+ })
+ return
+ }
+ // get grant before increasing counter
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err != nil || grant == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "grant does not exist",
+ })
+ return
+ }
+
+ // check if token got already used
+ if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "token was already used",
+ })
+ log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
+ return
+ }
+ accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ ctx.JSON(http.StatusOK, accessToken)
+}
+
+func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
+ })
+ return
+ }
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+ if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unexpected redirect URI",
+ })
+ return
+ }
+ authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
+ if err != nil || authorizationCode == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // check if code verifier authorizes the client, PKCE support
+ if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "failed PKCE code challenge",
+ })
+ return
+ }
+ // check if granted for this application
+ if authorizationCode.Grant.ApplicationID != app.ID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "invalid grant",
+ })
+ return
+ }
+ // remove token from database to deny duplicate usage
+ if err := authorizationCode.Invalidate(ctx); err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot proceed your request",
+ })
+ }
+ resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ // send successful response
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
+ ctx.JSON(http.StatusBadRequest, acErr)
+}
+
+func handleServerError(ctx *context.Context, state, redirectURI string, err error) {
+ log.Error("OAuth server error: %v", err)
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "A server error occurred",
+ State: state,
+ }, redirectURI)
+}
+
+func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
+ if redirectURI == "" {
+ log.Warn("Authorization failed: %v", authErr.ErrorDescription)
+ ctx.Data["Error"] = authErr
+ ctx.HTML(http.StatusBadRequest, tplGrantError)
+ return
+ }
+ redirect, err := url.Parse(redirectURI)
+ if err != nil {
+ ctx.ServerError("url.Parse", err)
+ return
+ }
+ q := redirect.Query()
+ q.Set("error", string(authErr.ErrorCode))
+ q.Set("error_description", authErr.ErrorDescription)
+ q.Set("state", authErr.State)
+ redirect.RawQuery = q.Encode()
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// SignInOAuth handles the OAuth2 login buttons
+func SignInOAuth(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ redirectTo := ctx.FormString("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ }
+
+ // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
+ user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err == nil && user != nil {
+ // we got the user without going through the whole OAuth2 authentication flow again
+ handleOAuth2SignIn(ctx, authSource, user, gothUser)
+ return
+ }
+
+ codeChallenge, err := generateCodeChallenge(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err))
+ return
+ }
+
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ if strings.Contains(err.Error(), "no provider for ") {
+ if err = oauth2.ResetOAuth2(ctx); err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ ctx.ServerError("SignIn", err)
+ }
+ return
+ }
+ ctx.ServerError("SignIn", err)
+ }
+ // redirect is done in oauth2.Auth
+}
+
+// SignInOAuthCallback handles the callback from the given provider
+func SignInOAuthCallback(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ if ctx.Req.FormValue("error") != "" {
+ var errorKeyValues []string
+ for k, vv := range ctx.Req.Form {
+ for _, v := range vv {
+ errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
+ }
+ }
+ sort.Strings(errorKeyValues)
+ ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
+ }
+
+ // first look if the provider is still active
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ if authSource == nil {
+ ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
+ return
+ }
+
+ u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err != nil {
+ if user_model.IsErrUserProhibitLogin(err) {
+ uplerr := err.(user_model.ErrUserProhibitLogin)
+ log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ return
+ }
+ if callbackErr, ok := err.(errCallback); ok {
+ log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
+ switch callbackErr.Code {
+ case "access_denied":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
+ case "temporarily_unavailable":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
+ default:
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error"))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ if err, ok := err.(*go_oauth2.RetrieveError); ok {
+ ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if u == nil {
+ if ctx.Doer != nil {
+ // attach user to already logged in user
+ err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
+ if err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
+ // create new user with details from oauth2 provider
+ if gothUser.UserID == "" {
+ log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" {
+ log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
+ }
+ err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+ var missingFields []string
+ if gothUser.Email == "" {
+ missingFields = append(missingFields, "email")
+ }
+ if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
+ missingFields = append(missingFields, "nickname")
+ }
+ if len(missingFields) > 0 {
+ // we don't have enough information to create an account automatically,
+ // so we prompt the user for the remaining bits
+ log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields)
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ uname, err := getUserName(&gothUser)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ u = &user_model.User{
+ Name: uname,
+ FullName: gothUser.Name,
+ Email: gothUser.Email,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
+ }
+
+ source := authSource.Cfg.(*oauth2.Source)
+
+ isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
+ u.IsAdmin = isAdmin.ValueOrDefault(false)
+ u.IsRestricted = isRestricted.ValueOrDefault(false)
+
+ if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
+ // error already handled
+ return
+ }
+
+ if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ } else {
+ // no existing user is found, request attach or new account
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ }
+
+ handleOAuth2SignIn(ctx, authSource, u, gothUser)
+}
+
+func claimValueToStringSet(claimValue any) container.Set[string] {
+ var groups []string
+
+ switch rawGroup := claimValue.(type) {
+ case []string:
+ groups = rawGroup
+ case []any:
+ for _, group := range rawGroup {
+ groups = append(groups, fmt.Sprintf("%s", group))
+ }
+ default:
+ str := fmt.Sprintf("%s", rawGroup)
+ groups = strings.Split(str, ",")
+ }
+ return container.SetOf(groups...)
+}
+
+func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
+ if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
+ if err != nil {
+ return err
+ }
+
+ groups := getClaimedGroups(source, gothUser)
+
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
+ groupClaims, has := gothUser.RawData[source.GroupClaimName]
+ if !has {
+ return nil
+ }
+
+ return claimValueToStringSet(groupClaims)
+}
+
+func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
+ groups := getClaimedGroups(source, gothUser)
+
+ if source.AdminGroup != "" {
+ isAdmin = optional.Some(groups.Contains(source.AdminGroup))
+ }
+ if source.RestrictedGroup != "" {
+ isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
+ }
+
+ return isAdmin, isRestricted
+}
+
+func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
+ if err := updateSession(ctx, nil, map[string]any{
+ "linkAccountGothUser": gothUser,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/link_account")
+}
+
+func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
+ if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
+ resp, err := http.Get(url)
+ if err == nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ // ignore any error
+ if err == nil && resp.StatusCode == http.StatusOK {
+ data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
+ if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
+ _ = user_service.UploadAvatar(ctx, u, data)
+ }
+ }
+ }
+}
+
+func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
+ updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+
+ needs2FA := false
+ if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA {
+ _, err := auth.GetTwoFactorByUID(ctx, u.ID)
+ if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ needs2FA = err == nil
+ }
+
+ oauth2Source := source.Cfg.(*oauth2.Source)
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
+ if err != nil {
+ ctx.ServerError("UnmarshalGroupTeamMapping", err)
+ return
+ }
+
+ groups := getClaimedGroups(oauth2Source, &gothUser)
+
+ // If this user is enrolled in 2FA and this source doesn't override it,
+ // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
+ if !needs2FA {
+ if err := updateSession(ctx, nil, map[string]any{
+ "uid": u.ID,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // Clear whatever CSRF cookie has right now, force to generate a new one
+ ctx.Csrf.DeleteCookie(ctx)
+
+ opts := &user_service.UpdateOptions{
+ SetLastLogin: true,
+ }
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ // update external user information
+ if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
+ if !errors.Is(err, util.ErrNotExist) {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+ }
+
+ if err := resetLocale(ctx, u); err != nil {
+ ctx.ServerError("resetLocale", err)
+ return
+ }
+
+ if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+ return
+ }
+
+ opts := &user_service.UpdateOptions{}
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ if err := updateSession(ctx, nil, map[string]any{
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ "twofaUid": u.ID,
+ "twofaRemember": false,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // If WebAuthn is enrolled -> Redirect to WebAuthn instead
+ regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/webauthn")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE
+func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) {
+ // the `code_verifier` is only forwarded by specific providers
+ // https://codeberg.org/forgejo/forgejo/issues/4033
+ p, ok := goth.GetProviders()[provider]
+ if !ok {
+ return "", nil
+ }
+ switch p.(type) {
+ default:
+ return "", nil
+ case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider:
+ // those providers forward the `code_verifier`
+ // a code_challenge can be generated
+ }
+
+ codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness)
+ if err != nil {
+ return "", err
+ }
+ if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil {
+ return "", err
+ }
+ return encodeCodeChallenge(codeVerifier)
+}
+
+func encodeCodeChallenge(codeVerifier string) (string, error) {
+ hasher := sha256.New()
+ _, err := io.WriteString(hasher, codeVerifier)
+ codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
+ return codeChallenge, err
+}
+
+// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
+// login the user
+func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
+ gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
+ if err != nil {
+ return nil, goth.User{}, err
+ }
+
+ if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
+ return nil, goth.User{}, err
+ }
+
+ u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
+ return u, gothUser, err
+}
+
+func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
+ oauth2Source := authSource.Cfg.(*oauth2.Source)
+
+ // Make sure that the response is not an error response.
+ errorName := request.FormValue("error")
+
+ if len(errorName) > 0 {
+ errorDescription := request.FormValue("error_description")
+
+ // Delete the goth session
+ err := gothic.Logout(response, request)
+ if err != nil {
+ return goth.User{}, err
+ }
+
+ return goth.User{}, errCallback{
+ Code: errorName,
+ Description: errorDescription,
+ }
+ }
+
+ // Proceed to authenticate through goth.
+ codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string)
+ _ = ctx.Session.Delete("CodeVerifier")
+ gothUser, err := oauth2Source.Callback(request, response, codeVerifier)
+ if err != nil {
+ if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
+ log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ }
+ return goth.User{}, err
+ }
+
+ if oauth2Source.RequiredClaimName != "" {
+ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+ if !has {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+
+ if oauth2Source.RequiredClaimValue != "" {
+ groups := claimValueToStringSet(claimInterface)
+
+ if !groups.Contains(oauth2Source.RequiredClaimValue) {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+ }
+ }
+
+ return gothUser, nil
+}
+
+func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
+ user := &user_model.User{
+ LoginName: gothUser.UserID,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ }
+
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ if hasUser {
+ return user, nil
+ }
+ log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
+
+ // search in external linked users
+ externalLoginUser := &user_model.ExternalLoginUser{
+ ExternalID: gothUser.UserID,
+ LoginSourceID: authSource.ID,
+ }
+ hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
+ if err != nil {
+ return nil, err
+ }
+ if hasUser {
+ user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
+ return user, err
+ }
+
+ // no user found to login
+ return nil, nil
+}
diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go
new file mode 100644
index 0000000..5a4a646
--- /dev/null
+++ b/routers/web/auth/oauth_test.go
@@ -0,0 +1,103 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken {
+ signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32))
+ require.NoError(t, err)
+ assert.NotNil(t, signingKey)
+
+ response, terr := newAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey)
+ assert.Nil(t, terr)
+ assert.NotNil(t, response)
+
+ parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2.OIDCToken{}, func(token *jwt.Token) (any, error) {
+ assert.NotNil(t, token.Method)
+ assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg())
+ return signingKey.VerifyKey(), nil
+ })
+ require.NoError(t, err)
+ assert.True(t, parsedToken.Valid)
+
+ oidcToken, ok := parsedToken.Claims.(*oauth2.OIDCToken)
+ assert.True(t, ok)
+ assert.NotNil(t, oidcToken)
+
+ return oidcToken
+}
+
+func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ grants, err := auth.GetOAuth2GrantsByUserID(db.DefaultContext, 3)
+ require.NoError(t, err)
+ assert.Len(t, grants, 1)
+
+ // Scopes: openid
+ oidcToken := createAndParseToken(t, grants[0])
+ assert.Empty(t, oidcToken.Name)
+ assert.Empty(t, oidcToken.PreferredUsername)
+ assert.Empty(t, oidcToken.Profile)
+ assert.Empty(t, oidcToken.Picture)
+ assert.Empty(t, oidcToken.Website)
+ assert.Empty(t, oidcToken.UpdatedAt)
+ assert.Empty(t, oidcToken.Email)
+ assert.False(t, oidcToken.EmailVerified)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ grants, err = auth.GetOAuth2GrantsByUserID(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, grants, 1)
+
+ // Scopes: openid profile email
+ oidcToken = createAndParseToken(t, grants[0])
+ assert.Equal(t, user.Name, oidcToken.Name)
+ assert.Equal(t, user.Name, oidcToken.PreferredUsername)
+ assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
+ assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)
+ assert.Equal(t, user.Website, oidcToken.Website)
+ assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt)
+ assert.Equal(t, user.Email, oidcToken.Email)
+ assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
+
+ // set DefaultShowFullName to true
+ oldDefaultShowFullName := setting.UI.DefaultShowFullName
+ setting.UI.DefaultShowFullName = true
+ defer func() {
+ setting.UI.DefaultShowFullName = oldDefaultShowFullName
+ }()
+
+ // Scopes: openid profile email
+ oidcToken = createAndParseToken(t, grants[0])
+ assert.Equal(t, user.FullName, oidcToken.Name)
+ assert.Equal(t, user.Name, oidcToken.PreferredUsername)
+ assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
+ assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)
+ assert.Equal(t, user.Website, oidcToken.Website)
+ assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt)
+ assert.Equal(t, user.Email, oidcToken.Email)
+ assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
+}
+
+func TestEncodeCodeChallenge(t *testing.T) {
+ // test vector from https://datatracker.ietf.org/doc/html/rfc7636#page-18
+ codeChallenge, err := encodeCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
+ require.NoError(t, err)
+ assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", codeChallenge)
+}
diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go
new file mode 100644
index 0000000..83268fa
--- /dev/null
+++ b/routers/web/auth/openid.go
@@ -0,0 +1,391 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/openid"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplSignInOpenID base.TplName = "user/auth/signin_openid"
+ tplConnectOID base.TplName = "user/auth/signup_openid_connect"
+ tplSignUpOID base.TplName = "user/auth/signup_openid_register"
+)
+
+// SignInOpenID render sign in page
+func SignInOpenID(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ if ctx.FormString("openid.return_to") != "" {
+ signInOpenIDVerify(ctx)
+ return
+ }
+
+ if CheckAutoLogin(ctx) {
+ return
+ }
+
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLoginOpenID"] = true
+ ctx.HTML(http.StatusOK, tplSignInOpenID)
+}
+
+// Check if the given OpenID URI is allowed by blacklist/whitelist
+func allowedOpenIDURI(uri string) (err error) {
+ // In case a Whitelist is present, URI must be in it
+ // in order to be accepted
+ if len(setting.Service.OpenIDWhitelist) != 0 {
+ for _, pat := range setting.Service.OpenIDWhitelist {
+ if pat.MatchString(uri) {
+ return nil // pass
+ }
+ }
+ // must match one of this or be refused
+ return fmt.Errorf("URI not allowed by whitelist")
+ }
+
+ // A blacklist match expliclty forbids
+ for _, pat := range setting.Service.OpenIDBlacklist {
+ if pat.MatchString(uri) {
+ return fmt.Errorf("URI forbidden by blacklist")
+ }
+ }
+
+ return nil
+}
+
+// SignInOpenIDPost response for openid sign in request
+func SignInOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.SignInOpenIDForm)
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLoginOpenID"] = true
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSignInOpenID)
+ return
+ }
+
+ id, err := openid.Normalize(form.Openid)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form)
+ return
+ }
+ form.Openid = id
+
+ log.Trace("OpenID uri: " + id)
+
+ err = allowedOpenIDURI(id)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form)
+ return
+ }
+
+ redirectTo := setting.AppURL + "user/login/openid"
+ url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
+ if err != nil {
+ log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error())
+ ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), tplSignInOpenID, &form)
+ return
+ }
+
+ // Request optional nickname and email info
+ // NOTE: change to `openid.sreg.required` to require it
+ url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1"
+ url += "&openid.sreg.optional=nickname%2Cemail"
+
+ log.Trace("Form-passed openid-remember: %t", form.Remember)
+
+ if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil {
+ log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err)
+ }
+
+ ctx.Redirect(url)
+}
+
+// signInOpenIDVerify handles response from OpenID provider
+func signInOpenIDVerify(ctx *context.Context) {
+ log.Trace("Incoming call to: %s", ctx.Req.URL.String())
+
+ fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
+ log.Trace("Full URL: %s", fullURL)
+
+ id, err := openid.Verify(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+
+ log.Trace("Verified ID: %s", id)
+
+ /* Now we should seek for the user and log him in, or prompt
+ * to register if not found */
+
+ u, err := user_model.GetUserByOpenID(ctx, id)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ log.Error("signInOpenIDVerify: %v", err)
+ }
+ if u != nil {
+ log.Trace("User exists, logging in")
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ log.Trace("User with openid: %s does not exist, should connect or register", id)
+
+ parsedURL, err := url.Parse(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ values, err := url.ParseQuery(parsedURL.RawQuery)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ email := values.Get("openid.sreg.email")
+ nickname := values.Get("openid.sreg.nickname")
+
+ log.Trace("User has email=%s and nickname=%s", email, nickname)
+
+ if email != "" {
+ u, err = user_model.GetUserByEmail(ctx, email)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ log.Error("signInOpenIDVerify: %v", err)
+ }
+ if u != nil {
+ log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email)
+ }
+ }
+
+ if u == nil && nickname != "" {
+ u, _ = user_model.GetUserByName(ctx, nickname)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ }
+ if u != nil {
+ log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname)
+ }
+ }
+
+ if u != nil {
+ nickname = u.LowerName
+ }
+ if err := updateSession(ctx, nil, map[string]any{
+ "openid_verified_uri": id,
+ "openid_determined_email": email,
+ "openid_determined_username": nickname,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration {
+ ctx.Redirect(setting.AppSubURL + "/user/openid/connect")
+ } else {
+ ctx.Redirect(setting.AppSubURL + "/user/openid/register")
+ }
+}
+
+// ConnectOpenID shows a form to connect an OpenID URI to an existing account
+func ConnectOpenID(ctx *context.Context) {
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID connect"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDConnect"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["OpenID"] = oid
+ userName, _ := ctx.Session.Get("openid_determined_username").(string)
+ if userName != "" {
+ ctx.Data["user_name"] = userName
+ }
+ ctx.HTML(http.StatusOK, tplConnectOID)
+}
+
+// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account
+func ConnectOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.ConnectOpenIDForm)
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID connect"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDConnect"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["OpenID"] = oid
+
+ u, _, err := auth.UserSignIn(ctx, form.UserName, form.Password)
+ if err != nil {
+ handleSignInError(ctx, form.UserName, &form, tplConnectOID, "ConnectOpenIDPost", err)
+ return
+ }
+
+ // add OpenID for the user
+ userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
+ if err = user_model.AddUserOpenID(ctx, userOID); err != nil {
+ if user_model.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form)
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
+
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+}
+
+// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI
+func RegisterOpenID(ctx *context.Context) {
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID signup"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDRegister"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
+ ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
+ ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
+ ctx.Data["OpenID"] = oid
+ userName, _ := ctx.Session.Get("openid_determined_username").(string)
+ if userName != "" {
+ ctx.Data["user_name"] = userName
+ }
+ email, _ := ctx.Session.Get("openid_determined_email").(string)
+ if email != "" {
+ ctx.Data["email"] = email
+ }
+ ctx.HTML(http.StatusOK, tplSignUpOID)
+}
+
+// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI
+func RegisterOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.SignUpOpenIDForm)
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+
+ ctx.Data["Title"] = "OpenID signup"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDRegister"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ context.SetCaptchaData(ctx)
+ ctx.Data["OpenID"] = oid
+
+ if setting.Service.AllowOnlyInternalRegistration {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if setting.Service.EnableCaptcha {
+ if err := ctx.Req.ParseForm(); err != nil {
+ ctx.ServerError("", err)
+ return
+ }
+ context.VerifyCaptcha(ctx, tplSignUpOID, form)
+ }
+
+ length := setting.MinPasswordLength
+ if length < 256 {
+ length = 256
+ }
+ password, err := util.CryptoRandomString(int64(length))
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignUpOID, form)
+ return
+ }
+
+ u := &user_model.User{
+ Name: form.UserName,
+ Email: form.Email,
+ Passwd: password,
+ }
+ if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) {
+ // error already handled
+ return
+ }
+
+ // add OpenID for the user
+ userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
+ if err = user_model.AddUserOpenID(ctx, userOID); err != nil {
+ if user_model.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form)
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+
+ if !handleUserCreated(ctx, u, nil) {
+ // error already handled
+ return
+ }
+
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+}
diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go
new file mode 100644
index 0000000..363c01c
--- /dev/null
+++ b/routers/web/auth/password.go
@@ -0,0 +1,325 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+var (
+ // tplMustChangePassword template for updating a user's password
+ tplMustChangePassword base.TplName = "user/auth/change_passwd"
+ tplForgotPassword base.TplName = "user/auth/forgot_passwd"
+ tplResetPassword base.TplName = "user/auth/reset_passwd"
+)
+
+// ForgotPasswd render the forget password page
+func ForgotPasswd(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
+
+ if setting.MailService == nil {
+ log.Warn("no mail service configured")
+ ctx.Data["IsResetDisable"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ ctx.Data["Email"] = ctx.FormString("email")
+
+ ctx.Data["IsResetRequest"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+}
+
+// ForgotPasswdPost response for forget password request
+func ForgotPasswdPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
+
+ if setting.MailService == nil {
+ ctx.NotFound("ForgotPasswdPost", nil)
+ return
+ }
+ ctx.Data["IsResetRequest"] = true
+
+ email := ctx.FormString("email")
+ ctx.Data["Email"] = email
+
+ u, err := user_model.GetUserByEmail(ctx, email)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale)
+ ctx.Data["IsResetSent"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ ctx.ServerError("user.ResetPasswd(check existence)", err)
+ return
+ }
+
+ if !u.IsLocal() && !u.IsOAuth2() {
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil)
+ return
+ }
+
+ if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
+ ctx.Data["ResendLimited"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ if err := mailer.SendResetPasswordMail(ctx, u); err != nil {
+ ctx.ServerError("SendResetPasswordMail", err)
+ return
+ }
+
+ if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+
+ ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale)
+ ctx.Data["IsResetSent"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+}
+
+func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_model.User, *auth.TwoFactor) {
+ code := ctx.FormString("code")
+
+ ctx.Data["Title"] = ctx.Tr("auth.reset_password")
+ ctx.Data["Code"] = code
+
+ if nil != ctx.Doer {
+ ctx.Data["user_signed_in"] = true
+ }
+
+ if len(code) == 0 {
+ ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true)
+ return nil, nil
+ }
+
+ // Fail early, don't frustrate the user
+ u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset, shouldDeleteToken)
+ if err != nil {
+ ctx.ServerError("VerifyUserAuthorizationToken", err)
+ return nil, nil
+ }
+
+ if u == nil {
+ ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true)
+ return nil, nil
+ }
+
+ twofa, err := auth.GetTwoFactorByUID(ctx, u.ID)
+ if err != nil {
+ if !auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error())
+ return nil, nil
+ }
+ } else {
+ ctx.Data["has_two_factor"] = true
+ ctx.Data["scratch_code"] = ctx.FormBool("scratch_code")
+ }
+
+ // Show the user that they are affecting the account that they intended to
+ ctx.Data["user_email"] = u.Email
+
+ if nil != ctx.Doer && u.ID != ctx.Doer.ID {
+ ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email), true)
+ return nil, nil
+ }
+
+ return u, twofa
+}
+
+// ResetPasswd render the account recovery page
+func ResetPasswd(ctx *context.Context) {
+ ctx.Data["IsResetForm"] = true
+
+ commonResetPassword(ctx, false)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplResetPassword)
+}
+
+// ResetPasswdPost response from account recovery request
+func ResetPasswdPost(ctx *context.Context) {
+ u, twofa := commonResetPassword(ctx, true)
+ if ctx.Written() {
+ return
+ }
+
+ if u == nil {
+ // Flash error has been set
+ ctx.HTML(http.StatusOK, tplResetPassword)
+ return
+ }
+
+ // Handle two-factor
+ regenerateScratchToken := false
+ if twofa != nil {
+ if ctx.FormBool("scratch_code") {
+ if !twofa.VerifyScratchToken(ctx.FormString("token")) {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Token"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil)
+ return
+ }
+ regenerateScratchToken = true
+ } else {
+ passcode := ctx.FormString("passcode")
+ ok, err := twofa.ValidateTOTP(passcode)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error())
+ return
+ }
+ if !ok || twofa.LastUsedPasscode == passcode {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Passcode"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
+ return
+ }
+
+ twofa.LastUsedPasscode = passcode
+ if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
+ ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
+ return
+ }
+ }
+ }
+
+ opts := &user_service.UpdateAuthOptions{
+ Password: optional.Some(ctx.FormString("password")),
+ MustChangePassword: optional.Some(false),
+ }
+ if err := user_service.UpdateAuth(ctx, u, opts); err != nil {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Password"] = true
+ switch {
+ case errors.Is(err, password.ErrMinLength):
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil)
+ case errors.Is(err, password.ErrComplexity):
+ ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil)
+ case errors.Is(err, password.ErrIsPwned):
+ ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil)
+ case password.IsErrIsPwnedRequest(err):
+ ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil)
+ default:
+ ctx.ServerError("UpdateAuth", err)
+ }
+ return
+ }
+
+ log.Trace("User password reset: %s", u.Name)
+ ctx.Data["IsResetFailed"] = true
+ remember := len(ctx.FormString("remember")) != 0
+
+ if regenerateScratchToken {
+ // Invalidate the scratch token.
+ _, err := twofa.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignInFull(ctx, u, remember, false)
+ if ctx.Written() {
+ return
+ }
+ ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ handleSignIn(ctx, u, remember)
+}
+
+// MustChangePassword renders the page to change a user's password
+func MustChangePassword(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
+ ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
+ ctx.Data["MustChangePassword"] = true
+ ctx.HTML(http.StatusOK, tplMustChangePassword)
+}
+
+// MustChangePasswordPost response for updating a user's password after their
+// account was created by an admin
+func MustChangePasswordPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.MustChangePasswordForm)
+ ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
+ ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplMustChangePassword)
+ return
+ }
+
+ // Make sure only requests for users who are eligible to change their password via
+ // this method passes through
+ if !ctx.Doer.MustChangePassword {
+ ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
+ return
+ }
+
+ if form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form)
+ return
+ }
+
+ opts := &user_service.UpdateAuthOptions{
+ Password: optional.Some(form.Password),
+ MustChangePassword: optional.Some(false),
+ }
+ if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil {
+ switch {
+ case errors.Is(err, password.ErrMinLength):
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form)
+ case errors.Is(err, password.ErrComplexity):
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form)
+ case errors.Is(err, password.ErrIsPwned):
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form)
+ case password.IsErrIsPwnedRequest(err):
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplMustChangePassword, &form)
+ default:
+ ctx.ServerError("UpdateAuth", err)
+ }
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
+
+ log.Trace("User updated password: %s", ctx.Doer.Name)
+
+ redirectTo := ctx.GetSiteCookie("redirect_to")
+ if redirectTo != "" {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ }
+ ctx.RedirectToFirst(redirectTo)
+}
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
new file mode 100644
index 0000000..5c93c14
--- /dev/null
+++ b/routers/web/auth/webauthn.go
@@ -0,0 +1,177 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ wa "code.gitea.io/gitea/modules/auth/webauthn"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/externalaccount"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+var tplWebAuthn base.TplName = "user/auth/webauthn"
+
+// WebAuthn shows the WebAuthn login page
+func WebAuthn(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa")
+
+ if CheckAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
+ return
+ }
+
+ hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Session.Get("twofaUid").(int64))
+ if err != nil {
+ ctx.ServerError("HasTwoFactorByUID", err)
+ return
+ }
+
+ ctx.Data["HasTwoFactor"] = hasTwoFactor
+
+ ctx.HTML(http.StatusOK, tplWebAuthn)
+}
+
+// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
+func WebAuthnLoginAssertion(ctx *context.Context) {
+ // Ensure user is in a WebAuthn session.
+ idSess, ok := ctx.Session.Get("twofaUid").(int64)
+ if !ok || idSess == 0 {
+ ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
+ return
+ }
+
+ user, err := user_model.GetUserByID(ctx, idSess)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ exists, err := auth.ExistsWebAuthnCredentialsForUID(ctx, user.ID)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if !exists {
+ ctx.ServerError("UserSignIn", errors.New("no device registered"))
+ return
+ }
+
+ assertion, sessionData, err := wa.WebAuthn.BeginLogin((*wa.User)(user))
+ if err != nil {
+ ctx.ServerError("webauthn.BeginLogin", err)
+ return
+ }
+
+ if err := ctx.Session.Set("webauthnAssertion", sessionData); err != nil {
+ ctx.ServerError("Session.Set", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, assertion)
+}
+
+// WebAuthnLoginAssertionPost validates the signature and logs the user in
+func WebAuthnLoginAssertionPost(ctx *context.Context) {
+ idSess, ok := ctx.Session.Get("twofaUid").(int64)
+ sessionData, okData := ctx.Session.Get("webauthnAssertion").(*webauthn.SessionData)
+ if !ok || !okData || sessionData == nil || idSess == 0 {
+ ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
+ return
+ }
+ defer func() {
+ _ = ctx.Session.Delete("webauthnAssertion")
+ }()
+
+ // Load the user from the db
+ user, err := user_model.GetUserByID(ctx, idSess)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ log.Trace("Finishing webauthn authentication with user: %s", user.Name)
+
+ // Now we do the equivalent of webauthn.FinishLogin using a combination of our session data
+ // (from webauthnAssertion) and verify the provided request.0
+ parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
+ if err != nil {
+ // Failed authentication attempt.
+ log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, parsedResponse.RawID)
+ if err != nil {
+ ctx.ServerError("GetWebAuthnCredentialByCredID", err)
+ return
+ }
+
+ // If the credential is legacy, assume the values are correct. The
+ // specification mandates these flags don't change.
+ if dbCred.Legacy {
+ dbCred.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
+ dbCred.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()
+ dbCred.Legacy = false
+
+ if err := dbCred.UpdateFromLegacy(ctx); err != nil {
+ ctx.ServerError("UpdateFromLegacy", err)
+ return
+ }
+ }
+
+ // Validate the parsed response.
+ cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse)
+ if err != nil {
+ // Failed authentication attempt.
+ log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ // Ensure that the credential wasn't cloned by checking if CloneWarning is set.
+ // (This is set if the sign counter is less than the one we have stored.)
+ if cred.Authenticator.CloneWarning {
+ log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
+ ctx.Status(http.StatusForbidden)
+ return
+ }
+
+ dbCred.SignCount = cred.Authenticator.SignCount
+ if err := dbCred.UpdateSignCount(ctx); err != nil {
+ ctx.ServerError("UpdateSignCount", err)
+ return
+ }
+
+ // Now handle account linking if that's requested
+ if ctx.Session.Get("linkAccount") != nil {
+ if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil {
+ ctx.ServerError("LinkAccountFromStore", err)
+ return
+ }
+ }
+
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ redirect := handleSignInFull(ctx, user, remember, false)
+ if redirect == "" {
+ redirect = setting.AppSubURL + "/"
+ }
+ _ = ctx.Session.Delete("twofaUid")
+
+ ctx.JSONRedirect(redirect)
+}