初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
var (
tplTwofa templates.TplName = "user/auth/twofa"
tplTwofaScratch templates.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 performAutoLogin(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 = linkAccountFromContext(ctx, 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
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
}
ctx.RenderWithErrDeprecated(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 performAutoLogin(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)
if ctx.Written() {
return
}
ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
}
+954
View File
@@ -0,0 +1,954 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/eventsource"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
user_service "gitea.dev/services/user"
"github.com/markbates/goth"
)
const (
tplSignIn templates.TplName = "user/auth/signin" // for sign in page
tplSignUp templates.TplName = "user/auth/signup" // for sign up page
TplActivate templates.TplName = "user/auth/activate" // for activate user
TplActivatePrompt templates.TplName = "user/auth/activate_prompt" // for showing a message for user activation
)
type CommonAuthOptions struct {
EnableCaptcha bool
}
func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
// for OpenID Connect
ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
if opt.EnableCaptcha {
ctx.Data["EnableCaptcha"] = true
ctx.Data["RecaptchaAPIScriptURL"] = strings.TrimSuffix(setting.Service.RecaptchaURL, "/") + "/api.js"
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
ctx.Data["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
if setting.Service.CaptchaType == setting.ImageCaptcha {
ctx.Data["Captcha"] = context.GetImageCaptcha()
}
}
}
// autoSignIn reads cookie and try to auto-login.
func autoSignIn(ctx *context.Context) (bool, error) {
isSucceed := false
defer func() {
if !isSucceed {
ctx.DeleteSiteCookie(setting.CookieRememberName)
}
}()
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
log.Error("Failed to delete expired auth tokens: %v", err)
}
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
if err != nil {
switch err {
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
return false, nil
}
return false, err
}
if t == nil {
return false, nil
}
u, err := user_model.GetUserByID(ctx, t.UserID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return false, fmt.Errorf("GetUserByID: %w", err)
}
return false, nil
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err)
}
isSucceed = true
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
if err != nil {
return false, err
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
if err := updateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
return false, fmt.Errorf("unable to updateSession: %w", err)
}
if err := resetLocale(ctx, u); err != nil {
return false, err
}
return true, nil
}
func resetLocale(ctx *context.Context, u *user_model.User) error {
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
if u.Language == "" {
opts := &user_service.UpdateOptions{
Language: optional.Some(ctx.Locale.Language()),
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
return err
}
}
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
if ctx.Locale.Language() != u.Language {
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
}
return nil
}
func rememberAuthRedirectLink(ctx *context.Context) {
redirectTo := ctx.FormString("redirect_to")
if redirectTo == "" {
if ref, err := url.Parse(ctx.Req.Referer()); err == nil && httplib.IsCurrentGiteaSiteURL(ctx, ctx.Req.Referer()) {
// the request paths starting with "/user/" are either:
// * auth related pages: don't redirect back to them
// * user settings pages: they have "require sign-in" protection already, no "referer redirect" would happen
skipRefererRedirect := strings.HasPrefix(ref.Path, setting.AppSubURL+"/user/")
if !skipRefererRedirect {
redirectTo = ref.RequestURI()
}
}
}
if redirectTo != "" {
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
}
}
func consumeAuthRedirectLink(ctx *context.Context) string {
redirects := []string{ctx.FormString("redirect_to"), middleware.GetRedirectToCookie(ctx.Req)}
middleware.DeleteRedirectToCookie(ctx.Resp)
if setting.LandingPageURL == setting.LandingPageLogin {
redirects = append(redirects, setting.AppSubURL+"/") // do not cycle-redirect to the login page
} else {
redirects = append(redirects, setting.AppSubURL+string(setting.LandingPageURL))
}
for _, link := range redirects {
if link != "" && httplib.IsCurrentGiteaSiteURL(ctx, link) {
return link
}
}
return setting.AppSubURL + "/"
}
func redirectAfterAuth(ctx *context.Context) {
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
}
func performAutoLogin(ctx *context.Context) bool {
rememberAuthRedirectLink(ctx)
isSucceed, err := autoSignIn(ctx) // try to auto-login
if err != nil {
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
return false
}
ctx.ServerError("autoSignIn", err)
return true
}
if isSucceed {
redirectAfterAuth(ctx)
return true
}
return false
}
func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool {
// If only 1 OAuth provider is present and other login methods are disabled, redirect to the OAuth provider.
onlySingleOAuth2 := len(data.oauth2Providers) == 1 &&
!setting.Service.EnablePasswordSignInForm &&
!setting.Service.EnableOpenIDSignIn &&
!setting.Service.EnablePasskeyAuth &&
!data.enableSSPI
if !onlySingleOAuth2 {
return false
}
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName())
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
}
ctx.Redirect(skipToOAuthURL)
return true
}
type preparedSignInData struct {
oauth2Providers []oauth2.Provider
enableSSPI bool
}
func prepareSignInPageData(ctx *context.Context) (ret preparedSignInData) {
var err error
ret.enableSSPI = auth.IsSSPIEnabled(ctx)
ret.oauth2Providers, err = oauth2.GetOAuth2Providers(ctx, optional.Some(true))
if err != nil {
log.Error("Failed to get OAuth2 providers: %v", err)
}
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["OAuth2Providers"] = ret.oauth2Providers
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login"
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsLogin"] = true
ctx.Data["EnableSSPI"] = ret.enableSSPI
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin,
})
return ret
}
// SignIn render sign in page
func SignIn(ctx *context.Context) {
if performAutoLogin(ctx) {
return
}
if ctx.IsSigned {
redirectAfterAuth(ctx)
return
}
data := prepareSignInPageData(ctx)
if performAutoLoginOAuth2(ctx, &data) {
return
}
ctx.HTML(http.StatusOK, tplSignIn)
}
// SignInPost response for sign in request
func SignInPost(ctx *context.Context) {
if !setting.Service.EnablePasswordSignInForm {
ctx.HTTPError(http.StatusForbidden)
return
}
prepareSignInPageData(ctx)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSignIn)
return
}
form := web.GetForm(ctx).(*forms.SignInForm)
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
context.VerifyCaptcha(ctx, tplSignIn, form)
if ctx.Written() {
return
}
}
u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password)
if err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form)
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
} else if user_model.IsErrEmailAlreadyUsed(err) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplSignIn, &form)
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
} else if user_model.IsErrUserProhibitLogin(err) {
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else {
ctx.ServerError("UserSignIn", err)
}
return
}
// Now handle 2FA:
// First of all if the source can skip local two fa we're done
if source.TwoFactorShouldSkip() {
handleSignIn(ctx, u, form.Remember)
return
}
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
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) {
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
}
redirectAfterAuth(ctx)
}
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
if remember {
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
if err != nil {
ctx.ServerError("CreateAuthTokenForUserID", err)
return
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if err := updateSession(ctx, []string{
// Delete the openid, 2fa and link_account data
"openid_verified_uri",
"openid_signin_remember",
"openid_determined_email",
"openid_determined_username",
"twofaUid",
"twofaRemember",
"linkAccount",
"linkAccountData",
}, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return
}
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
if u.Language == "" {
opts := &user_service.UpdateOptions{
Language: optional.Some(ctx.Locale.Language()),
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language()))
return
}
}
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
if ctx.Locale.Language() != u.Language {
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
}
// Register last login
if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
}
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
// It returns ("", nil) if the required field doesn't exist.
func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
switch setting.OAuth2Client.Username {
case setting.OAuth2UsernameEmail:
return user_model.NormalizeUserName(gothUser.Email)
case setting.OAuth2UsernamePreferredUsername:
if preferredUsername, ok := gothUser.RawData["preferred_username"].(string); ok {
return user_model.NormalizeUserName(preferredUsername)
}
return "", nil
case setting.OAuth2UsernameNickname:
return user_model.NormalizeUserName(gothUser.NickName)
default: // OAuth2UsernameUserid
return gothUser.UserID, nil
}
}
// HandleSignOut resets the session and sets the cookies
func HandleSignOut(ctx *context.Context) {
_ = ctx.Session.Flush()
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
ctx.DeleteSiteCookie(setting.CookieRememberName)
middleware.DeleteRedirectToCookie(ctx.Resp)
}
// SignOut sign out from login status
func SignOut(ctx *context.Context) {
if ctx.Doer != nil {
eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{
Name: "logout",
Data: ctx.Session.ID(),
})
}
// prepare the sign-out URL before destroying the session
redirectTo := buildSignOutRedirectURL(ctx)
HandleSignOut(ctx)
ctx.Redirect(redirectTo)
}
func buildSignOutRedirectURL(ctx *context.Context) string {
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 {
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
return s
}
}
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
// TODO: in the future, if we need to distinguish different sign-in methods, we need to save the sign-in method in session and check here
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
return setting.ReverseProxyLogoutRedirect
}
return setting.AppSubURL + "/"
}
func prepareSignUpPageData(ctx *context.Context) bool {
ctx.Data["Title"] = ctx.Tr("sign_up")
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
ctx.Data["PageIsSignUp"] = true
ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx)
hasUsers, err := user_model.HasUsers(ctx)
if err != nil {
ctx.ServerError("HasUsers", err)
return false
}
ctx.Data["IsFirstTimeRegistration"] = !hasUsers.HasAnyUser
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
if err != nil {
ctx.ServerError("GetOAuth2Providers", err)
return false
}
ctx.Data["OAuth2Providers"] = oauth2Providers
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha,
})
// Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
return true
}
// SignUp render the register page
func SignUp(ctx *context.Context) {
if !prepareSignUpPageData(ctx) {
return
}
rememberAuthRedirectLink(ctx)
ctx.HTML(http.StatusOK, tplSignUp)
}
// SignUpPost response for sign up information submission
func SignUpPost(ctx *context.Context) {
if !prepareSignUpPageData(ctx) {
return
}
form := web.GetForm(ctx).(*forms.RegisterForm)
// Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSignUp)
return
}
context.VerifyCaptcha(ctx, tplSignUp, form)
if ctx.Written() {
return
}
if !form.IsEmailDomainAllowed() {
ctx.RenderWithErrDeprecated(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
return
}
if form.Password != form.Retype {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplSignUp, &form)
return
}
if len(form.Password) < setting.MinPasswordLength {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
return
}
if !password.IsComplexEnough(form.Password) {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplSignUp, &form)
return
}
if err := password.IsPwned(ctx, form.Password); err != nil {
errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")
if password.IsErrIsPwnedRequest(err) {
log.Error(err.Error())
errMsg = ctx.Tr("auth.password_pwned_err")
}
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(errMsg, tplSignUp, &form)
return
}
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
}
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) {
// error already handled
return
}
ctx.Flash.Success(ctx.Tr("auth.sign_up_successful"))
handleSignIn(ctx, u, false)
}
// createAndHandleCreatedUser calls createUserInContext and
// then handleUserCreated.
func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
return false
}
return handleUserCreated(ctx, u, possibleLinkAccountData)
}
// createUserInContext creates a user and handles errors within a given context.
// Optionally, a template can be specified.
func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
meta := &user_model.Meta{
InitialIP: ctx.RemoteAddr(),
InitialUserAgent: ctx.Req.UserAgent(),
}
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
switch setting.OAuth2Client.AccountLinking {
case setting.OAuth2AccountLinkingAuto:
var user *user_model.User
user = &user_model.User{Name: u.Name}
hasUser, err := user_model.GetIndividualUser(ctx, user)
if !hasUser || err != nil {
user = &user_model.User{Email: u.Email}
hasUser, err = user_model.GetIndividualUser(ctx, user)
if !hasUser || err != nil {
ctx.ServerError("UserLinkAccount", err)
return false
}
}
// TODO: probably we should respect 'remember' user's choice...
oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
return false // user is already created here, all redirects are handled
case setting.OAuth2AccountLinkingLogin:
showLinkingLogin(ctx, possibleLinkAccountData.AuthSourceID, possibleLinkAccountData.GothUser)
return false // user will be created only after linking login
}
}
// handle error without a template
if len(tpl) == 0 {
ctx.ServerError("CreateUser", err)
return false
}
// handle error with template
switch {
case user_model.IsErrUserAlreadyExist(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tpl, form)
case user_model.IsErrEmailAlreadyUsed(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tpl, form)
case user_model.IsErrEmailCharIsNotSupported(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
case user_model.IsErrEmailInvalid(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
case db.IsErrNameReserved(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form)
case db.IsErrNamePatternNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form)
case db.IsErrNameCharsNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(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, possibleLinkAccountData *LinkAccountData) (ok bool) {
// Auto-set admin for the only user.
hasUsers, err := user_model.HasUsers(ctx)
if err != nil {
ctx.ServerError("HasUsers", err)
return false
}
if hasUsers.HasOnlyOneUser {
// the only user is the one just created, will set it as admin
opts := &user_service.UpdateOptions{
IsActive: optional.Some(true),
IsAdmin: user_service.UpdateOptionFieldFromValue(true),
SetLastLogin: true,
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", err)
return false
}
}
// update external user information
if possibleLinkAccountData != nil {
if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSourceID, u, possibleLinkAccountData.GothUser); err != nil {
log.Error("EnsureLinkExternalToUser failed: %v", err)
}
}
// for active user or the first (admin) user, we don't need to send confirmation email
if u.IsActive || u.ID == 1 {
return true
}
if setting.Service.RegisterManualConfirm {
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only"))
return false
}
sendActivateEmail(ctx, u)
return false
}
func renderActivationPromptMessage(ctx *context.Context, msg template.HTML) {
ctx.Data["ActivationPromptMessage"] = msg
ctx.HTML(http.StatusOK, TplActivatePrompt)
}
func sendActivateEmail(ctx *context.Context, u *user_model.User) {
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
return
}
if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err)
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
return
}
mailer.SendActivateAccountMail(ctx.Locale, u)
activeCodeLives := timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
msgHTML := ctx.Locale.Tr("auth.confirmation_mail_sent_prompt_ex", u.Email, activeCodeLives)
renderActivationPromptMessage(ctx, msgHTML)
}
func renderActivationVerifyPassword(ctx *context.Context, code string) {
ctx.Data["ActivationCode"] = code
ctx.Data["NeedVerifyLocalPassword"] = true
ctx.HTML(http.StatusOK, TplActivate)
}
func renderActivationChangeEmail(ctx *context.Context) {
ctx.HTML(http.StatusOK, TplActivate)
}
// Activate render activate user page
func Activate(ctx *context.Context) {
code := ctx.FormString("code")
if code == "" {
if ctx.Doer == nil {
ctx.Redirect(setting.AppSubURL + "/user/login")
return
} else if ctx.Doer.IsActive {
ctx.Redirect(setting.AppSubURL + "/")
return
}
if setting.MailService == nil || !setting.Service.RegisterEmailConfirm {
renderActivationPromptMessage(ctx, ctx.Tr("auth.disable_register_mail"))
return
}
// Resend confirmation email. FIXME: ideally this should be in a POST request
sendActivateEmail(ctx, ctx.Doer)
return
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return
}
// if account is local account, verify password
if user.LoginSource == 0 {
renderActivationVerifyPassword(ctx, code)
return
}
handleAccountActivation(ctx, user)
}
// ActivatePost handles account activation with password check
func ActivatePost(ctx *context.Context) {
code := ctx.FormString("code")
if ctx.Doer != nil && ctx.Doer.IsActive {
ctx.Redirect(setting.AppSubURL + "/user/activate") // it will redirect again to the correct page
return
}
if code == "" {
newEmail := strings.TrimSpace(ctx.FormString("change_email"))
if ctx.Doer != nil && newEmail != "" && !strings.EqualFold(ctx.Doer.Email, newEmail) {
if user_model.ValidateEmail(newEmail) != nil {
ctx.Flash.Error(ctx.Locale.Tr("form.email_invalid"), true)
renderActivationChangeEmail(ctx)
return
}
err := user_model.ChangeInactivePrimaryEmail(ctx, ctx.Doer.ID, ctx.Doer.Email, newEmail)
if err != nil {
ctx.Flash.Error(ctx.Locale.Tr("admin.emails.not_updated", newEmail), true)
renderActivationChangeEmail(ctx)
return
}
ctx.Doer.Email = newEmail
}
// FIXME: at the moment, GET request handles the "send confirmation email" action. But the old code does this redirect and then send a confirmation email.
ctx.Redirect(setting.AppSubURL + "/user/activate")
return
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return
}
// if account is local account, verify password
if user.LoginSource == 0 {
password := ctx.FormString("password")
if password == "" {
renderActivationVerifyPassword(ctx, code)
return
}
if !user.ValidatePassword(password) {
ctx.Flash.Error(ctx.Locale.Tr("auth.invalid_password"), true)
renderActivationVerifyPassword(ctx, code)
return
}
}
handleAccountActivation(ctx, user)
}
func handleAccountActivation(ctx *context.Context, user *user_model.User) {
user.IsActive = true
var err error
if user.Rands, err = user_model.GetUserSalt(); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("UpdateUser", err)
}
return
}
if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil {
log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err)
ctx.ServerError("ActivateUserEmail", err)
return
}
log.Trace("User activated: %s", user.Name)
if err := updateSession(ctx, nil, map[string]any{
"uid": user.ID,
"uname": user.Name,
}); err != nil {
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
ctx.ServerError("ActivateUserEmail", err)
return
}
if err := resetLocale(ctx, user); err != nil {
ctx.ServerError("resetLocale", err)
return
}
if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
ctx.Flash.Success(ctx.Tr("auth.account_activated"))
redirectAfterAuth(ctx)
}
// ActivateEmail render the activate email page
func ActivateEmail(ctx *context.Context) {
code := ctx.FormString("code")
emailStr := ctx.FormString("email")
// Verify code.
if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil {
if err := user_model.ActivateEmail(ctx, email); err != nil {
ctx.ServerError("ActivateEmail", err)
return
}
log.Trace("Email activated: %s", email.Email)
ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
log.Warn("GetUserByID: %d", email.UID)
} else {
// Allow user to validate more emails
_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
}
}
// FIXME: e-mail verification does not require the user to be logged in,
// so this could be redirecting to the login page.
// Should users be logged in automatically here? (consider 2FA requirements, etc.)
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
}
func 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
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"testing"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/contexttest"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
cfg.Provider = util.IfZero(cfg.Provider, "gitea")
err := auth_model.CreateSource(t.Context(), &auth_model.Source{
Type: auth_model.OAuth2,
Name: authName,
IsActive: true,
Cfg: &cfg,
})
require.NoError(t, err)
}
func TestWebAuthUserLogin(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))
}
func TestWebAuthOAuth2(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
_ = oauth2.Init(t.Context())
addOAuth2Source(t, "dummy+auth's source", oauth2.Source{})
t.Run("OAuth2MissingField", func(t *testing.T) {
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{Provider: "dummy+auth's source", UserID: "dummy-user"}, nil
})()
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/oauth2/..../callback?code=dummy-code", mockOpt)
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
SignInOAuthCallback(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
// then the user will be redirected to the link account page, and see a message about the missing fields
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
LinkAccount(ctx)
assert.Equal(t, template.HTML("auth.oauth_callback_unable_auto_reg:dummy+auth&#39;s source,email"), ctx.Data["AutoRegistrationFailedPrompt"])
})
t.Run("OAuth2CallbackError", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/oauth2/...../callback", mockOpt)
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
SignInOAuthCallback(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/login", test.RedirectURL(resp))
assert.Contains(t, ctx.Flash.ErrorMsg, "auth.oauth.signin.error.general")
})
t.Run("RedirectSingleProvider", func(t *testing.T) {
enablePassword := &setting.Service.EnablePasswordSignInForm
enableOpenID := &setting.Service.EnableOpenIDSignIn
enablePasskey := &setting.Service.EnablePasskeyAuth
defer test.MockVariableValue(enablePassword, false)()
defer test.MockVariableValue(enableOpenID, false)()
defer test.MockVariableValue(enablePasskey, false)()
testSignIn := func(t *testing.T, link string, expectedCode int, expectedRedirect string) {
ctx, resp := contexttest.MockContext(t, link)
SignIn(ctx)
assert.Equal(t, expectedCode, resp.Code)
if expectedCode == http.StatusSeeOther {
assert.Equal(t, expectedRedirect, test.RedirectURL(resp))
}
}
testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source")
testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source?redirect_to=%2F")
*enablePassword, *enableOpenID, *enablePasskey = true, false, false
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, true, false
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, false, true
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, false, false
addOAuth2Source(t, "dummy-auth-source-2", oauth2.Source{})
testSignIn(t, "/user/login", http.StatusOK, "")
})
t.Run("OIDCLogout", func(t *testing.T) {
var mockServer *httptest.Server
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_, _ = w.Write([]byte(`{
"issuer": "` + mockServer.URL + `",
"authorization_endpoint": "` + mockServer.URL + `/authorize",
"token_endpoint": "` + mockServer.URL + `/token",
"userinfo_endpoint": "` + mockServer.URL + `/userinfo",
"end_session_endpoint": "https://example.com/oidc-logout?oidc-key=oidc-val"
}`))
default:
http.NotFound(w, r)
}
}))
defer mockServer.Close()
addOAuth2Source(t, "oidc-auth-source", oauth2.Source{
Provider: "openidConnect",
ClientID: "mock-client-id",
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
require.NoError(t, err)
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
require.NoError(t, err)
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
assert.Equal(t, expectedValues, u.Query())
u.RawQuery = ""
assert.Equal(t, "https://example.com/oidc-logout", u.String())
})
}
+281
View File
@@ -0,0 +1,281 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"strings"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
"gitea.dev/services/forms"
)
var tplLinkAccount templates.TplName = "user/auth/link_account"
func prepareLinkAccountPageData(ctx *context.Context) {
// 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
// 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"
ctx.Data["ShowRegistrationButton"] = false
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
})
}
// LinkAccount shows the page where the user can decide to login or create a new account
func LinkAccount(ctx *context.Context) {
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
// If you'd like to quickly debug the "link account" page layout, just uncomment the blow line
// Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign)
// linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check
if linkAccountData == nil {
// no account in session, so just redirect to the login page, then the user could restart the process
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ","))
}
uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
email := linkAccountData.GothUser.Email
ctx.Data["user_name"] = uname
ctx.Data["email"] = email
if email != "" {
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 uname != "" {
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 templates.TplName, invoker string, err error) {
if errors.Is(err, util.ErrNotExist) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
} else if errors.Is(err, util.ErrInvalidArgument) {
ctx.Data["user_exists"] = true
ctx.RenderWithErrDeprecated(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 {
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["LinkAccountModeSignIn"] = true
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == 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
}
oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember)
}
func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) {
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if ctx.Written() {
return
}
// 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, linkAccountData.AuthSourceID, u, linkAccountData.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)
ctx.Data["LinkAccountModeRegister"] = true
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplLinkAccount)
return
}
if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
context.VerifyCaptcha(ctx, tplLinkAccount, form)
if ctx.Written() {
return
}
}
if !form.IsEmailDomainAllowed() {
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
return
}
}
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
LoginType: auth.OAuth2,
LoginSource: linkAccountData.AuthSourceID,
LoginName: linkAccountData.GothUser.UserID,
}
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) {
// error already handled
return
}
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if ctx.Written() {
return
}
authSource, err := auth.GetSourceByID(ctx, linkAccountData.AuthSourceID)
if err != nil {
ctx.ServerError("GetSourceByID", err)
return
}
source := authSource.Cfg.(*oauth2.Source)
if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
return
}
handleSignIn(ctx, u, false)
}
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
return errors.New("not in LinkAccount session")
}
return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, user, linkAccountData.GothUser)
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/gob"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/container"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
source_service "gitea.dev/services/auth/source"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
user_service "gitea.dev/services/user"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
go_oauth2 "golang.org/x/oauth2"
)
// SignInOAuth handles the OAuth2 login buttons
func SignInOAuth(ctx *context.Context) {
authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
return
}
rememberAuthRedirectLink(ctx)
// 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
}
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); 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); 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) {
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, "\n"), true)
}
// first look if the provider is still active
authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
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.general", callbackErr.Description))
}
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
if err, ok := err.(*go_oauth2.RetrieveError); ok {
ctx.Flash.Error("OAuth2 RetrieveError: " + err.Error())
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
ctx.ServerError("UserSignIn", err)
return
}
if u == nil {
if ctx.Doer != nil {
// attach user to the current signed-in user
err = externalaccount.LinkAccountToUser(ctx, authSource.ID, 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
var missingFields []string
if gothUser.UserID == "" {
missingFields = append(missingFields, "sub")
}
if gothUser.Email == "" {
missingFields = append(missingFields, "email")
}
uname, err := extractUserNameFromOAuth2(&gothUser)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if uname == "" {
switch setting.OAuth2Client.Username {
case setting.OAuth2UsernameNickname:
missingFields = append(missingFields, "nickname")
case setting.OAuth2UsernamePreferredUsername:
missingFields = append(missingFields, "preferred_username")
} // else: "UserID" and "Email" have been handled above separately
}
if len(missingFields) > 0 {
log.Error(`OAuth2 auto registration (ENABLE_AUTO_REGISTRATION) is enabled but OAuth2 provider %q doesn't return required fields: %s. `+
`Suggest to: disable auto registration, or make OPENID_CONNECT_SCOPES (for OpenIDConnect) / Authentication Source Scopes (for Admin panel) to request all required fields, and the fields shouldn't be empty.`,
authSource.Name, strings.Join(missingFields, ","))
// The RawData is the only way to pass the missing fields to the another page at the moment, other ways all have various problems:
// by session or cookie: difficult to clean or reset; by URL: could be injected with uncontrollable content; by ctx.Flash: the link_account page is a mess ...
// Since the RawData is for the provider's data, so we need to use our own prefix here to avoid conflict.
if gothUser.RawData == nil {
gothUser.RawData = make(map[string]any)
}
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
showLinkingLogin(ctx, authSource.ID, gothUser)
return
}
u = &user_model.User{
Name: uname,
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(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
linkAccountData := &LinkAccountData{authSource.ID, gothUser}
if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
linkAccountData = nil
}
if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
// 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, authSource.ID, 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 optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) {
groups := getClaimedGroups(source, gothUser)
if source.AdminGroup != "" {
isAdmin = user_service.UpdateOptionFieldFromSync(groups.Contains(source.AdminGroup))
}
if source.RestrictedGroup != "" {
isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
}
return isAdmin, isRestricted
}
type LinkAccountData struct {
AuthSourceID int64
GothUser goth.User
}
func init() {
gob.Register(LinkAccountData{}) // TODO: CHI-SESSION-GOB-REGISTER
}
func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
if !ok {
return nil
}
return &v
}
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{
"linkAccountData": linkAccountData,
})
}
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
if err := Oauth2SetLinkAccountData(ctx, LinkAccountData{authSourceID, gothUser}); err != nil {
ctx.ServerError("Oauth2SetLinkAccountData", err)
return
}
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil)
if err != nil {
log.Warn("invalid avatar URL %q: %v", avatarURL, err)
return
}
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
resp, err := oauth2AvatarHTTPClient.Do(req)
if err != nil {
log.Warn("fetch %q failed: %v", avatarURL, err)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
log.Warn("fetch %q returned status %d", avatarURL, resp.StatusCode)
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
if err != nil {
log.Warn("read body from %q failed: %v", avatarURL, err)
return
}
if int64(len(data)) > setting.Avatar.MaxFileSize {
log.Warn("avatar from %q exceeds max size %d", avatarURL, setting.Avatar.MaxFileSize)
return
}
if err := user_service.UploadAvatar(ctx, u, data); err != nil {
log.Warn("UploadAvatar for user %q failed: %v", u.Name, err)
}
}
func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
oauth2SignInSync(ctx, authSource.ID, u, gothUser)
if ctx.Written() {
return
}
needs2FA := false
if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserSignIn", err)
return
}
needs2FA = err == nil
}
oauth2Source := authSource.Cfg.(*oauth2.Source)
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
if err != nil {
ctx.ServerError("UnmarshalGroupTeamMapping", err)
return
}
groups := getClaimedGroups(oauth2Source, &gothUser)
opts := &user_service.UpdateOptions{}
// Reactivate user if they are deactivated
if !u.IsActive {
opts.IsActive = optional.Some(true)
}
// Update GroupClaims
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
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 := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
ctx.ServerError("EnsureLinkExternalToUser", err)
return
}
// 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 {
// Register last login
opts.SetLastLogin = true
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("UpdateUser", err)
return
}
if err := updateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
if err := resetLocale(ctx, u); err != nil {
ctx.ServerError("resetLocale", err)
return
}
redirectAfterAuth(ctx)
return
}
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", 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")
}
// 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) {
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 nil, goth.User{}, err
}
return nil, goth.User{}, errCallback{
Code: errorName,
Description: errorDescription,
}
}
// Proceed to authenticate through goth.
gothUser, err := oauth2Source.Callback(request, response)
if err != nil {
if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
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)
log.Error("oauth2Source.Callback failed: %v", err)
} else {
err = errCallback{Code: "internal", Description: err.Error()}
}
return nil, goth.User{}, err
}
if oauth2Source.RequiredClaimName != "" {
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
if !has {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
if oauth2Source.RequiredClaimValue != "" {
groups := claimValueToStringSet(claimInterface)
if !groups.Contains(oauth2Source.RequiredClaimValue) {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
}
}
user := &user_model.User{
LoginName: gothUser.UserID,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
}
hasUser, err := user_model.GetIndividualUser(ctx, user)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
return user, gothUser, nil
}
// search in external linked users
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
}
// no user found to login
return nil, gothUser, nil
}
// buildOIDCEndSessionURL constructs an OIDC RP-Initiated Logout URL for the
// given user. Returns "" if the user's auth source is not OIDC or doesn't
// advertise an end_session_endpoint.
func buildOIDCEndSessionURL(ctx *context.Context, doer *user_model.User) string {
authSource, err := auth.GetSourceByID(ctx, doer.LoginSource)
if err != nil {
log.Error("Failed to get auth source for OIDC logout (source=%d): %v", doer.LoginSource, err)
return ""
}
oauth2Cfg, ok := authSource.Cfg.(*oauth2.Source)
if !ok {
return ""
}
endSessionEndpoint := oauth2.GetOIDCEndSessionEndpoint(authSource.Name)
if endSessionEndpoint == "" {
return ""
}
endSessionURL, err := url.Parse(endSessionEndpoint)
if err != nil {
log.Error("Failed to parse end_session_endpoint %q: %v", endSessionEndpoint, err)
return ""
}
// RP-Initiated Logout 1.0: use client_id to identify the client to the IdP.
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
params := endSessionURL.Query()
params.Set("client_id", oauth2Cfg.ClientID)
// AWS Cognito uses "logout_uri" instead of the standard "post_logout_redirect_uri"
redirectURI := httplib.GuessCurrentAppURL(ctx)
if oauth2Cfg.Provider == oauth2.ProviderNameAwsCognito {
params.Set("logout_uri", redirectURI)
} else {
params.Set("post_logout_redirect_uri", redirectURI)
}
endSessionURL.RawQuery = params.Encode()
return endSessionURL.String()
}
+708
View File
@@ -0,0 +1,708 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/oauth2_provider"
"gitea.com/go-chi/binding"
jwt "github.com/golang-jwt/jwt/v5"
)
const (
tplGrantAccess templates.TplName = "user/auth/grant"
tplGrantError templates.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)
}
// errCallback represents a oauth2 callback error
type errCallback struct {
Code string
Description string
}
func (err errCallback) Error() string {
return err.Description
}
type userInfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
Groups []string `json:"groups"`
}
// 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="Gitea OAuth2"`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
response := &userInfoResponse{
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
Name: ctx.Doer.DisplayName(),
PreferredUsername: ctx.Doer.Name,
Email: ctx.Doer.Email,
Picture: ctx.Doer.AvatarLink(ctx),
}
var accessTokenScope auth.AccessTokenScope
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
}
}
// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
}
response.Groups = groups
ctx.JSON(http.StatusOK, response)
}
// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
authHeader := ctx.Req.Header.Get("Authorization")
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
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.HTTPError(http.StatusInternalServerError)
return
}
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
}
if !clientIDValid {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea OAuth2"`)
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_provider.ParseToken(form.Token, oauth2_provider.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.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, nil /*exp*/)
}
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 {
var errstring strings.Builder
for _, e := range errs {
errstring.WriteString(e.Error() + "\n")
}
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring.String()))
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", "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("CodeChallenge", 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)
return
}
// Redirect if user already granted access and the application is confidential or trusted otherwise
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
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
}
// check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
// 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)
log.Error(err.Error())
return
}
err = ctx.Session.Set("redirect_uri", form.RedirectURI)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("state", form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
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.HTTPError(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)
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)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
func OIDCWellKnown(ctx *context.Context) {
if !setting.OAuth2.Enabled {
http.NotFound(ctx.Resp, ctx.Req)
return
}
jwtRegisteredClaims := oauth2_provider.NewJwtRegisteredClaimsFromUser("well-known", 0, nil)
ctx.Data["OidcIssuer"] = jwtRegisteredClaims.Issuer // use the consistent issuer from the JWT registered claims
ctx.Data["OidcBaseUrl"] = strings.TrimSuffix(setting.AppURL, "/")
ctx.Data["SigningKeyMethodAlg"] = oauth2_provider.DefaultSigningKey.SigningMethod().Alg()
ctx.JSONTemplate("user/auth/oidc_wellknown")
}
// OIDCKeys generates the JSON Web Key Set
func OIDCKeys(ctx *context.Context) {
jwk, err := oauth2_provider.DefaultSigningKey.ToJWK()
if err != nil {
log.Error("Error converting signing key to JWK: %v", err)
ctx.HTTPError(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 == "" {
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
})
return
}
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_id in request body inconsistent with Authorization header",
})
return
}
form.ClientID = clientID
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
})
return
}
form.ClientSecret = clientSecret
}
}
serverKey := oauth2_provider.DefaultSigningKey
clientKey := serverKey
if serverKey.IsSymmetric() {
var err error
clientKey, err = oauth2_provider.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType,
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
})
}
}
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: errorDescription,
})
return
}
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "grant does not exist",
})
return
}
if grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "refresh token belongs to a different client",
})
return
}
// check if token got already used
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.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 := oauth2_provider.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_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: errorDescription,
})
return
}
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unexpected redirect URI",
})
return
}
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
if err != nil || authorizationCode == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "client is not authorized",
})
return
}
if authorizationCode.IsExpired() {
_ = authorizationCode.Invalidate(ctx)
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "authorization code expired",
})
return
}
// check if code verifier authorizes the client, PKCE support
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "failed PKCE code challenge",
})
return
}
if authorizationCode.RedirectURI != "" && form.RedirectURI != authorizationCode.RedirectURI {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "redirect_uri differs from the original authorization request",
})
return
}
// check if granted for this application
if authorizationCode.Grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "invalid grant",
})
return
}
// remove token from database to deny duplicate usage
if err := authorizationCode.Invalidate(ctx); err != nil {
errDescription := "cannot process your request"
errCode := oauth2_provider.AccessTokenErrorCodeInvalidRequest
if errors.Is(err, auth.ErrOAuth2AuthorizationCodeInvalidated) {
errDescription = "authorization code already used"
errCode = oauth2_provider.AccessTokenErrorCodeInvalidGrant
}
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: errCode,
ErrorDescription: errDescription,
})
return
}
resp, tokenErr := oauth2_provider.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 oauth2_provider.AccessTokenError) {
ctx.JSON(http.StatusBadRequest, acErr)
}
func handleServerError(ctx *context.Context, state, redirectURI string) {
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)
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"github.com/markbates/goth"
)
func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.User, gothUser goth.User) {
oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
authSource, err := auth.GetSourceByID(ctx, authSourceID)
if err != nil {
ctx.ServerError("GetSourceByID", err)
return
}
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if !authSource.IsOAuth2() || oauth2Source == nil {
ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider))
return
}
// sync full name
fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name")
fullName, _ := gothUser.RawData[fullNameKey].(string)
fullName = util.IfZero(fullName, gothUser.Name)
// need to update if the user has no full name set
shouldUpdateFullName := u.FullName == ""
// force to update if the attribute is set
shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != ""
// only update if the full name is different
shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName
if shouldUpdateFullName {
u.FullName = fullName
if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil {
log.Error("Unable to sync OAuth2 user full name %s: %v", gothUser.Provider, err)
}
}
err = oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u)
if err != nil {
log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err)
}
}
func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
value, exists := gothUser.RawData[source.SSHPublicKeyClaimName]
if !exists {
return []string{}, nil
}
rawSlice, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value type: %T", value)
}
sshKeys := make([]string, 0, len(rawSlice))
for _, v := range rawSlice {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value item type: %T", v)
}
sshKeys = append(sshKeys, str)
}
return sshKeys, nil
}
func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error {
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" {
return nil
}
sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser)
if err != nil {
return err
}
if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys, false) {
return nil
}
return asymkey_service.RewriteAllPublicKeys(ctx)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
assert.NoError(t, err)
assert.NotNil(t, signingKey)
response, terr := oauth2_provider.NewAccessTokenResponse(t.Context(), grant, signingKey, signingKey)
assert.Nil(t, terr)
assert.NotNil(t, response)
parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2_provider.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
})
assert.NoError(t, err)
assert.True(t, parsedToken.Valid)
oidcToken, ok := parsedToken.Claims.(*oauth2_provider.OIDCToken)
assert.True(t, ok)
assert.NotNil(t, oidcToken)
return oidcToken
}
func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
grants, err := auth.GetOAuth2GrantsByUserID(t.Context(), 3)
assert.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(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, grants, 1)
// Scopes: openid profile email
oidcToken = createAndParseToken(t, grants[0])
assert.Equal(t, user.DisplayName(), oidcToken.Name)
assert.Equal(t, user.Name, oidcToken.PreferredUsername)
assert.Equal(t, user.HTMLURL(t.Context()), oidcToken.Profile)
assert.Equal(t, user.AvatarLink(t.Context()), 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)
}
+379
View File
@@ -0,0 +1,379 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/openid"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
const (
tplSignInOpenID templates.TplName = "user/auth/signin_openid"
tplConnectOID templates.TplName = "user/auth/signup_openid_connect"
tplSignUpOID templates.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 performAutoLogin(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 errors.New("URI not allowed by whitelist")
}
// A blacklist match expliclty forbids
for _, pat := range setting.Service.OpenIDBlacklist {
if pat.MatchString(uri) {
return errors.New("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.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &form)
return
}
form.Openid = id
log.Trace("OpenID uri: " + id)
err = allowedOpenIDURI(id)
if err != nil {
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated("Unable to find OpenID provider in "+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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
values, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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")
}
}
func prepareConnectOpenIDPageData(ctx *context.Context) (oid string) {
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["OpenID"] = oid
prepareCommonAuthPageData(ctx, CommonAuthOptions{EnableCaptcha: false})
return oid
}
// ConnectOpenID shows a form to connect an OpenID URI to an existing account
func ConnectOpenID(ctx *context.Context) {
oid := prepareConnectOpenIDPageData(ctx)
if oid == "" {
return
}
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 := prepareConnectOpenIDPageData(ctx)
if oid == "" {
return
}
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.RenderWithErrDeprecated(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)
}
func prepareRegisterOpenIDPageData(ctx *context.Context) (oid string) {
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["OpenID"] = oid
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha,
})
return oid
}
// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI
func RegisterOpenID(ctx *context.Context) {
oid := prepareRegisterOpenIDPageData(ctx)
if oid == "" {
return
}
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) {
oid := prepareRegisterOpenIDPageData(ctx)
if oid == "" {
return
}
form := web.GetForm(ctx).(*forms.SignUpOpenIDForm)
if setting.Service.AllowOnlyInternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if setting.Service.EnableCaptcha {
if err := ctx.Req.ParseForm(); err != nil {
ctx.ServerError("", err)
return
}
context.VerifyCaptcha(ctx, tplSignUpOID, form)
}
length := max(setting.MinPasswordLength, 256)
password := util.CryptoRandomString(int64(length))
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: password,
}
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) {
// 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.RenderWithErrDeprecated(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)
}
+311
View File
@@ -0,0 +1,311 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
user_service "gitea.dev/services/user"
)
var (
// tplMustChangePassword template for updating a user's password
tplMustChangePassword templates.TplName = "user/auth/change_passwd"
tplForgotPassword templates.TplName = "user/auth/forgot_passwd"
tplResetPassword templates.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(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.RenderWithErrDeprecated(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
}
mailer.SendResetPasswordMail(u)
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) (*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", setting.AppSubURL+"/user/forgot_password"), true)
return nil, nil
}
// Fail early, don't frustrate the user
u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code)
if u == nil {
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true)
return nil, nil
}
twofa, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil {
if !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.HTTPError(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)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplResetPassword)
}
// ResetPasswdPost response from account recovery request
func ResetPasswdPost(ctx *context.Context) {
u, twofa := commonResetPassword(ctx)
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.RenderWithErrDeprecated(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.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
return
}
if !ok || twofa.LastUsedPasscode == passcode {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil)
case errors.Is(err, password.ErrComplexity):
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil)
case errors.Is(err, password.ErrIsPwned):
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil)
case password.IsErrIsPwnedRequest(err):
ctx.RenderWithErrDeprecated(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)
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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form)
case errors.Is(err, password.ErrComplexity):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form)
case errors.Is(err, password.ErrIsPwned):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form)
case password.IsErrIsPwnedRequest(err):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(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)
redirectAfterAuth(ctx)
}
+276
View File
@@ -0,0 +1,276 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/binary"
"errors"
"net/http"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
wa "gitea.dev/modules/auth/webauthn"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
var tplWebAuthn templates.TplName = "user/auth/webauthn"
// WebAuthn shows the WebAuthn login page
func WebAuthn(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
if performAutoLogin(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)
}
// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
func WebAuthnPasskeyAssertion(ctx *context.Context) {
if !setting.Service.EnablePasskeyAuth {
ctx.HTTPError(http.StatusForbidden)
return
}
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
if err != nil {
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
return
}
if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil {
ctx.ServerError("Session.Set", err)
return
}
ctx.JSON(http.StatusOK, assertion)
}
// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
func WebAuthnPasskeyLogin(ctx *context.Context) {
if !setting.Service.EnablePasskeyAuth {
ctx.HTTPError(http.StatusForbidden)
return
}
sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
if !okData || sessionData == nil {
ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
return
}
defer func() {
_ = ctx.Session.Delete("webauthnPasskeyAssertion")
}()
// Validate the parsed response.
// ParseCredentialRequestResponse+ValidateDiscoverableLogin equals to FinishDiscoverableLogin, but we need to ParseCredentialRequestResponse first to get flags
var user *user_model.User
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
}
cred, err := wa.WebAuthn.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
userID, n := binary.Varint(userHandle)
if n <= 0 {
return nil, errors.New("invalid rawID")
}
var err error
user, err = user_model.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
return wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags), nil
}, *sessionData, parsedResponse)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
if !cred.Flags.UserPresent {
ctx.Status(http.StatusBadRequest)
return
}
if user == nil {
ctx.Status(http.StatusBadRequest)
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
}
// Success! Get the credential and update the sign count with the new value we received.
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
if err != nil {
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
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 := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
}
remember := false // TODO: implement remember me
handleSignInFull(ctx, user, remember)
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
}
// 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
}
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
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
}
// Validate the parsed response.
webAuthnUser := wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags)
cred, err := wa.WebAuthn.ValidateLogin(webAuthnUser, *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
}
// Success! Get the credential and update the sign count with the new value we received.
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
if err != nil {
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
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 := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
}
remember := ctx.Session.Get("twofaRemember").(bool)
handleSignInFull(ctx, user, remember)
_ = ctx.Session.Delete("twofaUid")
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
}