gitea/routers/web/auth/oauth.go
wxiaoguang 0148d03f21
Enforce two-factor auth (2FA: TOTP or WebAuthn) (#34187)
Fix #880

Design:

1. A global setting `security.TWO_FACTOR_AUTH`.
* To support org-level config, we need to introduce a better "owner
setting" system first (in the future)
2. A user without 2FA can login and may explore, but can NOT read or
write to any repositories via API/web.
3. Keep things as simple as possible.
* This option only aggressively suggest users to enable their 2FA at the
moment, it does NOT guarantee that users must have 2FA before all other
operations, it should be good enough for real world use cases.
* Some details and tests could be improved in the future since this
change only adds a check and seems won't affect too much.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-04-28 15:31:59 -07:00

497 lines
15 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html"
"io"
"net/http"
"sort"
"strings"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
auth_module "code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
user_service "code.gitea.io/gitea/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) {
provider := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
if err != nil {
ctx.ServerError("SignIn", err)
return
}
redirectTo := ctx.FormString("redirect_to")
if len(redirectTo) > 0 {
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
}
// try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
if err == nil && user != nil {
// we got the user without going through the whole OAuth2 authentication flow again
handleOAuth2SignIn(ctx, authSource, user, gothUser)
return
}
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) {
provider := ctx.PathParam("provider")
if ctx.Req.FormValue("error") != "" {
var errorKeyValues []string
for k, vv := range ctx.Req.Form {
for _, v := range vv {
errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
}
}
sort.Strings(errorKeyValues)
ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
}
// first look if the provider is still active
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
if err != nil {
ctx.ServerError("SignIn", err)
return
}
if authSource == nil {
ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
return
}
u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
if err != nil {
if user_model.IsErrUserProhibitLogin(err) {
uplerr := err.(user_model.ErrUserProhibitLogin)
log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
return
}
if callbackErr, ok := err.(errCallback); ok {
log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
switch callbackErr.Code {
case "access_denied":
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
case "temporarily_unavailable":
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
default:
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.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(), true)
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, 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, gothUser)
return
}
u = &user_model.User{
Name: uname,
FullName: gothUser.Name,
Email: gothUser.Email,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
LoginName: gothUser.UserID,
}
overwriteDefault := &user_model.CreateUserOverwriteOptions{
IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
}
source := authSource.Cfg.(*oauth2.Source)
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
u.IsAdmin = isAdmin.ValueOrDefault(false)
u.IsRestricted = isRestricted.ValueOrDefault(false)
if !createAndHandleCreatedUser(ctx, templates.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
// error already handled
return
}
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
return
}
} else {
// no existing user is found, request attach or new account
showLinkingLogin(ctx, gothUser)
return
}
}
handleOAuth2SignIn(ctx, authSource, u, gothUser)
}
func claimValueToStringSet(claimValue any) container.Set[string] {
var groups []string
switch rawGroup := claimValue.(type) {
case []string:
groups = rawGroup
case []any:
for _, group := range rawGroup {
groups = append(groups, fmt.Sprintf("%s", group))
}
default:
str := fmt.Sprintf("%s", rawGroup)
groups = strings.Split(str, ",")
}
return container.SetOf(groups...)
}
func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
if err != nil {
return err
}
groups := getClaimedGroups(source, gothUser)
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
return err
}
}
return nil
}
func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
groupClaims, has := gothUser.RawData[source.GroupClaimName]
if !has {
return nil
}
return claimValueToStringSet(groupClaims)
}
func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
groups := getClaimedGroups(source, gothUser)
if source.AdminGroup != "" {
isAdmin = optional.Some(groups.Contains(source.AdminGroup))
}
if source.RestrictedGroup != "" {
isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
}
return isAdmin, isRestricted
}
func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
if err := updateSession(ctx, nil, map[string]any{
"linkAccountGothUser": gothUser,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
resp, err := http.Get(url)
if err == nil {
defer func() {
_ = resp.Body.Close()
}()
}
// ignore any error
if err == nil && resp.StatusCode == http.StatusOK {
data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
_ = user_service.UploadAvatar(ctx, u, data)
}
}
}
}
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
needs2FA := false
if !source.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserSignIn", err)
return
}
needs2FA = err == nil
}
oauth2Source := source.Cfg.(*oauth2.Source)
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
if err != nil {
ctx.ServerError("UnmarshalGroupTeamMapping", err)
return
}
groups := getClaimedGroups(oauth2Source, &gothUser)
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, 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
}
// force to generate a new CSRF token
ctx.Csrf.PrepareForSessionUser(ctx)
if err := resetLocale(ctx, u); err != nil {
ctx.ServerError("resetLocale", err)
return
}
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
middleware.DeleteRedirectToCookie(ctx.Resp)
ctx.RedirectToCurrentSite(redirectTo)
return
}
ctx.Redirect(setting.AppSubURL + "/")
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.GetUser(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
}