mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-01 13:01:15 +00:00
7cabc5670d
A remote user (UserTypeRemoteUser) is a placeholder that can be promoted to a regular user (UserTypeIndividual). It represents users that exist somewhere else. Although the UserTypeRemoteUser already exists in Forgejo, it is neither used or documented. A new login type / source (Remote) is introduced and set to be the login type of remote users. Type UserTypeRemoteUser LogingType Remote The association between a remote user and its counterpart in another environment (for instance another forge) is via the OAuth2 login source: LoginName set to the unique identifier relative to the login source LoginSource set to the identifier of the remote source For instance when migrating from GitLab.com, a user can be created as if it was authenticated using GitLab.com as an OAuth2 authentication source. When a user authenticates to Forejo from the same authentication source and the identifier match, the remote user is promoted to a regular user. For instance if 43 is the ID of the GitLab.com OAuth2 login source, 88 is the ID of the Remote loging source, and 48323 is the identifier of the foo user: Type UserTypeRemoteUser LogingType Remote LoginName 48323 LoginSource 88 Email (empty) Name foo Will be promoted to the following when the user foo authenticates to the Forgejo instance using GitLab.com as an OAuth2 provider. All users with a LoginType of Remote and a LoginName of 48323 are examined. If the LoginSource has a provider name that matches the provider name of GitLab.com (usually just "gitlab"), it is a match and can be promoted. The email is obtained via the OAuth2 provider and the user set to: Type UserTypeIndividual LogingType OAuth2 LoginName 48323 LoginSource 43 Email foo@example.com Name foo Note: the Remote login source is an indirection to the actual login source, i.e. the provider string my be set to a login source that does not exist yet.
1303 lines
42 KiB
Go
1303 lines
42 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
go_context "context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models/auth"
|
|
org_model "code.gitea.io/gitea/models/organization"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
auth_module "code.gitea.io/gitea/modules/auth"
|
|
"code.gitea.io/gitea/modules/base"
|
|
"code.gitea.io/gitea/modules/container"
|
|
"code.gitea.io/gitea/modules/json"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/optional"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/modules/web"
|
|
"code.gitea.io/gitea/modules/web/middleware"
|
|
auth_service "code.gitea.io/gitea/services/auth"
|
|
source_service "code.gitea.io/gitea/services/auth/source"
|
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
|
"code.gitea.io/gitea/services/context"
|
|
"code.gitea.io/gitea/services/externalaccount"
|
|
"code.gitea.io/gitea/services/forms"
|
|
remote_service "code.gitea.io/gitea/services/remote"
|
|
user_service "code.gitea.io/gitea/services/user"
|
|
|
|
"gitea.com/go-chi/binding"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/markbates/goth"
|
|
"github.com/markbates/goth/gothic"
|
|
go_oauth2 "golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
tplGrantAccess base.TplName = "user/auth/grant"
|
|
tplGrantError base.TplName = "user/auth/grant_error"
|
|
)
|
|
|
|
// TODO move error and responses to SDK or models
|
|
|
|
// AuthorizeErrorCode represents an error code specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
|
|
type AuthorizeErrorCode string
|
|
|
|
const (
|
|
// ErrorCodeInvalidRequest represents the according error in RFC 6749
|
|
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
|
|
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
|
|
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
|
|
// ErrorCodeAccessDenied represents the according error in RFC 6749
|
|
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
|
|
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
|
|
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
|
|
// ErrorCodeInvalidScope represents the according error in RFC 6749
|
|
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
|
|
// ErrorCodeServerError represents the according error in RFC 6749
|
|
ErrorCodeServerError AuthorizeErrorCode = "server_error"
|
|
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
|
|
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
|
|
)
|
|
|
|
// AuthorizeError represents an error type specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
|
|
type AuthorizeError struct {
|
|
ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
|
|
ErrorDescription string
|
|
State string
|
|
}
|
|
|
|
// Error returns the error message
|
|
func (err AuthorizeError) Error() string {
|
|
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
|
|
}
|
|
|
|
// AccessTokenErrorCode represents an error code specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
type AccessTokenErrorCode string
|
|
|
|
const (
|
|
// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
|
|
// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidClient = "invalid_client"
|
|
// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidGrant = "invalid_grant"
|
|
// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
|
|
// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
|
|
// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidScope = "invalid_scope"
|
|
)
|
|
|
|
// AccessTokenError represents an error response specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
type AccessTokenError struct {
|
|
ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
|
|
// Error returns the error message
|
|
func (err AccessTokenError) Error() string {
|
|
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
|
|
}
|
|
|
|
// errCallback represents a oauth2 callback error
|
|
type errCallback struct {
|
|
Code string
|
|
Description string
|
|
}
|
|
|
|
func (err errCallback) Error() string {
|
|
return err.Description
|
|
}
|
|
|
|
// TokenType specifies the kind of token
|
|
type TokenType string
|
|
|
|
const (
|
|
// TokenTypeBearer represents a token type specified in RFC 6749
|
|
TokenTypeBearer TokenType = "bearer"
|
|
// TokenTypeMAC represents a token type specified in RFC 6749
|
|
TokenTypeMAC = "mac"
|
|
)
|
|
|
|
// AccessTokenResponse represents a successful access token response
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
|
|
type AccessTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType TokenType `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
IDToken string `json:"id_token,omitempty"`
|
|
}
|
|
|
|
func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
|
if setting.OAuth2.InvalidateRefreshTokens {
|
|
if err := grant.IncreaseCounter(ctx); err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "cannot increase the grant counter",
|
|
}
|
|
}
|
|
}
|
|
// generate access token to access the API
|
|
expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
|
|
accessToken := &oauth2.Token{
|
|
GrantID: grant.ID,
|
|
Type: oauth2.TypeAccessToken,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
|
|
},
|
|
}
|
|
signedAccessToken, err := accessToken.SignToken(serverKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
|
|
// generate refresh token to request an access token after it expired later
|
|
refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
|
|
refreshToken := &oauth2.Token{
|
|
GrantID: grant.ID,
|
|
Counter: grant.Counter,
|
|
Type: oauth2.TypeRefreshToken,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
|
|
},
|
|
}
|
|
signedRefreshToken, err := refreshToken.SignToken(serverKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
|
|
// generate OpenID Connect id_token
|
|
signedIDToken := ""
|
|
if grant.ScopeContains("openid") {
|
|
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot find application",
|
|
}
|
|
}
|
|
user, err := user_model.GetUserByID(ctx, grant.UserID)
|
|
if err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot find user",
|
|
}
|
|
}
|
|
log.Error("Error loading user: %v", err)
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "server error",
|
|
}
|
|
}
|
|
|
|
idToken := &oauth2.OIDCToken{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
|
|
Issuer: setting.AppURL,
|
|
Audience: []string{app.ClientID},
|
|
Subject: fmt.Sprint(grant.UserID),
|
|
},
|
|
Nonce: grant.Nonce,
|
|
}
|
|
if grant.ScopeContains("profile") {
|
|
idToken.Name = user.GetDisplayName()
|
|
idToken.PreferredUsername = user.Name
|
|
idToken.Profile = user.HTMLURL()
|
|
idToken.Picture = user.AvatarLink(ctx)
|
|
idToken.Website = user.Website
|
|
idToken.Locale = user.Language
|
|
idToken.UpdatedAt = user.UpdatedUnix
|
|
}
|
|
if grant.ScopeContains("email") {
|
|
idToken.Email = user.Email
|
|
idToken.EmailVerified = user.IsActive
|
|
}
|
|
if grant.ScopeContains("groups") {
|
|
groups, err := getOAuthGroupsForUser(ctx, user)
|
|
if err != nil {
|
|
log.Error("Error getting groups: %v", err)
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "server error",
|
|
}
|
|
}
|
|
idToken.Groups = groups
|
|
}
|
|
|
|
signedIDToken, err = idToken.SignToken(clientKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
}
|
|
|
|
return &AccessTokenResponse{
|
|
AccessToken: signedAccessToken,
|
|
TokenType: TokenTypeBearer,
|
|
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
|
|
RefreshToken: signedRefreshToken,
|
|
IDToken: signedIDToken,
|
|
}, nil
|
|
}
|
|
|
|
type userInfoResponse struct {
|
|
Sub string `json:"sub"`
|
|
Name string `json:"name"`
|
|
Username string `json:"preferred_username"`
|
|
Email string `json:"email"`
|
|
Picture string `json:"picture"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
// InfoOAuth manages request for userinfo endpoint
|
|
func InfoOAuth(ctx *context.Context) {
|
|
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
|
|
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
|
|
return
|
|
}
|
|
|
|
response := &userInfoResponse{
|
|
Sub: fmt.Sprint(ctx.Doer.ID),
|
|
Name: ctx.Doer.FullName,
|
|
Username: ctx.Doer.Name,
|
|
Email: ctx.Doer.Email,
|
|
Picture: ctx.Doer.AvatarLink(ctx),
|
|
}
|
|
|
|
groups, err := getOAuthGroupsForUser(ctx, ctx.Doer)
|
|
if err != nil {
|
|
ctx.ServerError("Oauth groups for user", err)
|
|
return
|
|
}
|
|
response.Groups = groups
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// returns a list of "org" and "org:team" strings,
|
|
// that the given user is a part of.
|
|
func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]string, error) {
|
|
orgs, err := org_model.GetUserOrgsList(ctx, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
|
}
|
|
|
|
var groups []string
|
|
for _, org := range orgs {
|
|
groups = append(groups, org.Name)
|
|
teams, err := org.LoadTeams(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadTeams: %w", err)
|
|
}
|
|
for _, team := range teams {
|
|
if team.IsMember(ctx, user.ID) {
|
|
groups = append(groups, org.Name+":"+team.LowerName)
|
|
}
|
|
}
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
// IntrospectOAuth introspects an oauth token
|
|
func IntrospectOAuth(ctx *context.Context) {
|
|
if ctx.Doer == nil {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
|
|
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
|
|
return
|
|
}
|
|
|
|
var response struct {
|
|
Active bool `json:"active"`
|
|
Scope string `json:"scope,omitempty"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
|
|
token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
|
|
if err == nil {
|
|
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
|
|
if err == nil && grant != nil {
|
|
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
|
|
if err == nil && app != nil {
|
|
response.Active = true
|
|
response.Scope = grant.Scope
|
|
response.Issuer = setting.AppURL
|
|
response.Audience = []string{app.ClientID}
|
|
response.Subject = fmt.Sprint(grant.UserID)
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// AuthorizeOAuth manages authorize requests
|
|
func AuthorizeOAuth(ctx *context.Context) {
|
|
form := web.GetForm(ctx).(*forms.AuthorizationForm)
|
|
errs := binding.Errors{}
|
|
errs = form.Validate(ctx.Req, errs)
|
|
if len(errs) > 0 {
|
|
errstring := ""
|
|
for _, e := range errs {
|
|
errstring += e.Error() + "\n"
|
|
}
|
|
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
|
|
return
|
|
}
|
|
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
if auth.IsErrOauthClientIDInvalid(err) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "Client ID not registered",
|
|
State: form.State,
|
|
}, "")
|
|
return
|
|
}
|
|
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
|
|
return
|
|
}
|
|
|
|
var user *user_model.User
|
|
if app.UID != 0 {
|
|
user, err = user_model.GetUserByID(ctx, app.UID)
|
|
if err != nil {
|
|
ctx.ServerError("GetUserByID", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !app.ContainsRedirectURI(form.RedirectURI) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "Unregistered Redirect URI",
|
|
State: form.State,
|
|
}, "")
|
|
return
|
|
}
|
|
|
|
if form.ResponseType != "code" {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeUnsupportedResponseType,
|
|
ErrorDescription: "Only code response type is supported.",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
// pkce support
|
|
switch form.CodeChallengeMethod {
|
|
case "S256":
|
|
case "plain":
|
|
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "cannot set code challenge method",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "cannot set code challenge",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
// Here we're just going to try to release the session early
|
|
if err := ctx.Session.Release(); err != nil {
|
|
// we'll tolerate errors here as they *should* get saved elsewhere
|
|
log.Error("Unable to save changes to the session: %v", err)
|
|
}
|
|
case "":
|
|
// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
|
|
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
|
|
if !app.ConfidentialClient {
|
|
// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
|
|
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "PKCE is required for public clients",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
default:
|
|
// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
|
|
// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "unsupported code challenge method",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
|
|
if err != nil {
|
|
handleServerError(ctx, form.State, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
// Redirect if user already granted access
|
|
if 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
|
|
}
|
|
|
|
// 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"] = fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))
|
|
} else {
|
|
ctx.Data["ApplicationCreatorLinkHTML"] = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))
|
|
}
|
|
ctx.Data["ApplicationRedirectDomainHTML"] = "<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.Error(http.StatusBadRequest)
|
|
return
|
|
}
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
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) {
|
|
ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
|
|
ctx.JSONTemplate("user/auth/oidc_wellknown")
|
|
}
|
|
|
|
// OIDCKeys generates the JSON Web Key Set
|
|
func OIDCKeys(ctx *context.Context) {
|
|
jwk, err := oauth2.DefaultSigningKey.ToJWK()
|
|
if err != nil {
|
|
log.Error("Error converting signing key to JWK: %v", err)
|
|
ctx.Error(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jwk["use"] = "sig"
|
|
|
|
jwks := map[string][]map[string]string{
|
|
"keys": {
|
|
jwk,
|
|
},
|
|
}
|
|
|
|
ctx.Resp.Header().Set("Content-Type", "application/json")
|
|
enc := json.NewEncoder(ctx.Resp)
|
|
if err := enc.Encode(jwks); err != nil {
|
|
log.Error("Failed to encode representation as json. Error: %v", err)
|
|
}
|
|
}
|
|
|
|
// AccessTokenOAuth manages all access token requests by the client
|
|
func AccessTokenOAuth(ctx *context.Context) {
|
|
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
|
|
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
|
|
if form.ClientID == "" || form.ClientSecret == "" {
|
|
authHeader := ctx.Req.Header.Get("Authorization")
|
|
authContent := strings.SplitN(authHeader, " ", 2)
|
|
if len(authContent) == 2 && authContent[0] == "Basic" {
|
|
payload, err := base64.StdEncoding.DecodeString(authContent[1])
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot parse basic auth header",
|
|
})
|
|
return
|
|
}
|
|
pair := strings.SplitN(string(payload), ":", 2)
|
|
if len(pair) != 2 {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot parse basic auth header",
|
|
})
|
|
return
|
|
}
|
|
if form.ClientID != "" && form.ClientID != pair[0] {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "client_id in request body inconsistent with Authorization header",
|
|
})
|
|
return
|
|
}
|
|
form.ClientID = pair[0]
|
|
if form.ClientSecret != "" && form.ClientSecret != pair[1] {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
|
|
})
|
|
return
|
|
}
|
|
form.ClientSecret = pair[1]
|
|
}
|
|
}
|
|
|
|
serverKey := oauth2.DefaultSigningKey
|
|
clientKey := serverKey
|
|
if serverKey.IsSymmetric() {
|
|
var err error
|
|
clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "Error creating signing key",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
switch form.GrantType {
|
|
case "refresh_token":
|
|
handleRefreshToken(ctx, form, serverKey, clientKey)
|
|
case "authorization_code":
|
|
handleAuthorizationCode(ctx, form, serverKey, clientKey)
|
|
default:
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
|
|
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
|
|
})
|
|
return
|
|
}
|
|
// "The authorization server MUST ... require client authentication for confidential clients"
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
|
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
|
|
errorDescription := "invalid client secret"
|
|
if form.ClientSecret == "" {
|
|
errorDescription = "invalid empty client secret"
|
|
}
|
|
// "invalid_client ... Client authentication failed"
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: errorDescription,
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "unable to parse refresh token",
|
|
})
|
|
return
|
|
}
|
|
// get grant before increasing counter
|
|
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
|
|
if err != nil || grant == nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "grant does not exist",
|
|
})
|
|
return
|
|
}
|
|
|
|
// check if token got already used
|
|
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "token was already used",
|
|
})
|
|
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
|
|
return
|
|
}
|
|
accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
|
|
if tokenErr != nil {
|
|
handleAccessTokenError(ctx, *tokenErr)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, accessToken)
|
|
}
|
|
|
|
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
|
|
})
|
|
return
|
|
}
|
|
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
|
|
errorDescription := "invalid client secret"
|
|
if form.ClientSecret == "" {
|
|
errorDescription = "invalid empty client secret"
|
|
}
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: errorDescription,
|
|
})
|
|
return
|
|
}
|
|
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "unexpected redirect URI",
|
|
})
|
|
return
|
|
}
|
|
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
|
|
if err != nil || authorizationCode == nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "client is not authorized",
|
|
})
|
|
return
|
|
}
|
|
// check if code verifier authorizes the client, PKCE support
|
|
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "failed PKCE code challenge",
|
|
})
|
|
return
|
|
}
|
|
// check if granted for this application
|
|
if authorizationCode.Grant.ApplicationID != app.ID {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "invalid grant",
|
|
})
|
|
return
|
|
}
|
|
// remove token from database to deny duplicate usage
|
|
if err := authorizationCode.Invalidate(ctx); err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot proceed your request",
|
|
})
|
|
}
|
|
resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
|
|
if tokenErr != nil {
|
|
handleAccessTokenError(ctx, *tokenErr)
|
|
return
|
|
}
|
|
// send successful response
|
|
ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
|
|
ctx.JSON(http.StatusBadRequest, acErr)
|
|
}
|
|
|
|
func handleServerError(ctx *context.Context, state, redirectURI string) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "A server error occurred",
|
|
State: state,
|
|
}, redirectURI)
|
|
}
|
|
|
|
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
|
|
if redirectURI == "" {
|
|
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
|
|
ctx.Data["Error"] = authErr
|
|
ctx.HTML(http.StatusBadRequest, tplGrantError)
|
|
return
|
|
}
|
|
redirect, err := url.Parse(redirectURI)
|
|
if err != nil {
|
|
ctx.ServerError("url.Parse", err)
|
|
return
|
|
}
|
|
q := redirect.Query()
|
|
q.Set("error", string(authErr.ErrorCode))
|
|
q.Set("error_description", authErr.ErrorDescription)
|
|
q.Set("state", authErr.State)
|
|
redirect.RawQuery = q.Encode()
|
|
ctx.Redirect(redirect.String(), http.StatusSeeOther)
|
|
}
|
|
|
|
// SignInOAuth handles the OAuth2 login buttons
|
|
func SignInOAuth(ctx *context.Context) {
|
|
provider := ctx.Params(":provider")
|
|
|
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
|
if err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
return
|
|
}
|
|
|
|
redirectTo := ctx.FormString("redirect_to")
|
|
if len(redirectTo) > 0 {
|
|
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
|
}
|
|
|
|
// try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
|
|
user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
|
|
if err == nil && user != nil {
|
|
// we got the user without going through the whole OAuth2 authentication flow again
|
|
handleOAuth2SignIn(ctx, authSource, user, gothUser)
|
|
return
|
|
}
|
|
|
|
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.Params(":provider")
|
|
|
|
if ctx.Req.FormValue("error") != "" {
|
|
var errorKeyValues []string
|
|
for k, vv := range ctx.Req.Form {
|
|
for _, v := range vv {
|
|
errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
|
|
}
|
|
}
|
|
sort.Strings(errorKeyValues)
|
|
ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
|
|
}
|
|
|
|
// first look if the provider is still active
|
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
|
if err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
return
|
|
}
|
|
|
|
if authSource == nil {
|
|
ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
|
|
return
|
|
}
|
|
|
|
u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
|
|
if err != nil {
|
|
if user_model.IsErrUserProhibitLogin(err) {
|
|
uplerr := err.(user_model.ErrUserProhibitLogin)
|
|
log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
|
|
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
|
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
|
return
|
|
}
|
|
if callbackErr, ok := err.(errCallback); ok {
|
|
log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
|
|
switch callbackErr.Code {
|
|
case "access_denied":
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
|
|
case "temporarily_unavailable":
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
|
|
default:
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error"))
|
|
}
|
|
ctx.Redirect(setting.AppSubURL + "/user/login")
|
|
return
|
|
}
|
|
if err, ok := err.(*go_oauth2.RetrieveError); ok {
|
|
ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
|
|
}
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
|
|
if u == nil {
|
|
if ctx.Doer != nil {
|
|
// attach user to already logged in user
|
|
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
|
|
if err != nil {
|
|
ctx.ServerError("UserLinkAccount", err)
|
|
return
|
|
}
|
|
|
|
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
|
return
|
|
} else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
|
|
// create new user with details from oauth2 provider
|
|
if gothUser.UserID == "" {
|
|
log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
|
|
if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" {
|
|
log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
|
|
}
|
|
err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
|
|
ctx.ServerError("CreateUser", err)
|
|
return
|
|
}
|
|
var missingFields []string
|
|
if gothUser.Email == "" {
|
|
missingFields = append(missingFields, "email")
|
|
}
|
|
if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
|
|
missingFields = append(missingFields, "nickname")
|
|
}
|
|
if len(missingFields) > 0 {
|
|
// we don't have enough information to create an account automatically,
|
|
// so we prompt the user for the remaining bits
|
|
log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields)
|
|
showLinkingLogin(ctx, gothUser)
|
|
return
|
|
}
|
|
uname, err := getUserName(&gothUser)
|
|
if err != nil {
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
u = &user_model.User{
|
|
Name: uname,
|
|
FullName: gothUser.Name,
|
|
Email: gothUser.Email,
|
|
LoginType: auth.OAuth2,
|
|
LoginSource: authSource.ID,
|
|
LoginName: gothUser.UserID,
|
|
}
|
|
|
|
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
|
IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
|
|
}
|
|
|
|
source := authSource.Cfg.(*oauth2.Source)
|
|
|
|
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
|
|
u.IsAdmin = isAdmin.ValueOrDefault(false)
|
|
u.IsRestricted = isRestricted.ValueOrDefault(false)
|
|
|
|
if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
|
|
// error already handled
|
|
return
|
|
}
|
|
|
|
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
|
|
ctx.ServerError("SyncGroupsToTeams", err)
|
|
return
|
|
}
|
|
} else {
|
|
// no existing user is found, request attach or new account
|
|
showLinkingLogin(ctx, gothUser)
|
|
return
|
|
}
|
|
}
|
|
|
|
handleOAuth2SignIn(ctx, authSource, u, gothUser)
|
|
}
|
|
|
|
func claimValueToStringSet(claimValue any) container.Set[string] {
|
|
var groups []string
|
|
|
|
switch rawGroup := claimValue.(type) {
|
|
case []string:
|
|
groups = rawGroup
|
|
case []any:
|
|
for _, group := range rawGroup {
|
|
groups = append(groups, fmt.Sprintf("%s", group))
|
|
}
|
|
default:
|
|
str := fmt.Sprintf("%s", rawGroup)
|
|
groups = strings.Split(str, ",")
|
|
}
|
|
return container.SetOf(groups...)
|
|
}
|
|
|
|
func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
|
|
if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
|
|
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
groups := getClaimedGroups(source, gothUser)
|
|
|
|
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
|
|
groupClaims, has := gothUser.RawData[source.GroupClaimName]
|
|
if !has {
|
|
return nil
|
|
}
|
|
|
|
return claimValueToStringSet(groupClaims)
|
|
}
|
|
|
|
func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
|
|
groups := getClaimedGroups(source, gothUser)
|
|
|
|
if source.AdminGroup != "" {
|
|
isAdmin = optional.Some(groups.Contains(source.AdminGroup))
|
|
}
|
|
if source.RestrictedGroup != "" {
|
|
isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
|
|
}
|
|
|
|
return isAdmin, isRestricted
|
|
}
|
|
|
|
func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
|
|
if err := updateSession(ctx, nil, map[string]any{
|
|
"linkAccountGothUser": gothUser,
|
|
}); err != nil {
|
|
ctx.ServerError("updateSession", err)
|
|
return
|
|
}
|
|
ctx.Redirect(setting.AppSubURL + "/user/link_account")
|
|
}
|
|
|
|
func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
|
|
if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
|
|
resp, err := http.Get(url)
|
|
if err == nil {
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
}
|
|
// ignore any error
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
|
|
if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
|
|
_ = user_service.UploadAvatar(ctx, u, data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
|
|
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
|
|
|
|
needs2FA := false
|
|
if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA {
|
|
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
needs2FA = err == nil
|
|
}
|
|
|
|
oauth2Source := source.Cfg.(*oauth2.Source)
|
|
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
|
|
if err != nil {
|
|
ctx.ServerError("UnmarshalGroupTeamMapping", err)
|
|
return
|
|
}
|
|
|
|
groups := getClaimedGroups(oauth2Source, &gothUser)
|
|
|
|
// If this user is enrolled in 2FA and this source doesn't override it,
|
|
// we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
|
|
if !needs2FA {
|
|
if err := updateSession(ctx, nil, map[string]any{
|
|
"uid": u.ID,
|
|
}); err != nil {
|
|
ctx.ServerError("updateSession", err)
|
|
return
|
|
}
|
|
|
|
// Clear whatever CSRF cookie has right now, force to generate a new one
|
|
ctx.Csrf.DeleteCookie(ctx)
|
|
|
|
opts := &user_service.UpdateOptions{
|
|
SetLastLogin: true,
|
|
}
|
|
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
|
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return
|
|
}
|
|
|
|
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
|
|
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
|
|
ctx.ServerError("SyncGroupsToTeams", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// update external user information
|
|
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
|
|
if !errors.Is(err, util.ErrNotExist) {
|
|
log.Error("UpdateExternalUser failed: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := resetLocale(ctx, u); err != nil {
|
|
ctx.ServerError("resetLocale", err)
|
|
return
|
|
}
|
|
|
|
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
|
|
middleware.DeleteRedirectToCookie(ctx.Resp)
|
|
ctx.RedirectToFirst(redirectTo)
|
|
return
|
|
}
|
|
|
|
ctx.Redirect(setting.AppSubURL + "/")
|
|
return
|
|
}
|
|
|
|
opts := &user_service.UpdateOptions{}
|
|
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
|
if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
|
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
|
ctx.ServerError("UpdateUser", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
|
|
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
|
|
ctx.ServerError("SyncGroupsToTeams", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := updateSession(ctx, nil, map[string]any{
|
|
// User needs to use 2FA, save data and redirect to 2FA page.
|
|
"twofaUid": u.ID,
|
|
"twofaRemember": false,
|
|
}); err != nil {
|
|
ctx.ServerError("updateSession", err)
|
|
return
|
|
}
|
|
|
|
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
|
|
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
|
|
if err == nil && len(regs) > 0 {
|
|
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
|
return
|
|
}
|
|
|
|
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
|
}
|
|
|
|
func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
|
|
gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
|
|
if err != nil {
|
|
return nil, goth.User{}, err
|
|
}
|
|
|
|
if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
|
|
return nil, goth.User{}, err
|
|
}
|
|
|
|
u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
|
|
return u, gothUser, err
|
|
}
|
|
|
|
func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
|
|
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
|
|
|
// Make sure that the response is not an error response.
|
|
errorName := request.FormValue("error")
|
|
|
|
if len(errorName) > 0 {
|
|
errorDescription := request.FormValue("error_description")
|
|
|
|
// Delete the goth session
|
|
err := gothic.Logout(response, request)
|
|
if err != nil {
|
|
return goth.User{}, err
|
|
}
|
|
|
|
return goth.User{}, errCallback{
|
|
Code: errorName,
|
|
Description: errorDescription,
|
|
}
|
|
}
|
|
|
|
// Proceed to authenticate through goth.
|
|
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") {
|
|
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
|
err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
|
}
|
|
return goth.User{}, err
|
|
}
|
|
|
|
if oauth2Source.RequiredClaimName != "" {
|
|
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
|
|
if !has {
|
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
|
}
|
|
|
|
if oauth2Source.RequiredClaimValue != "" {
|
|
groups := claimValueToStringSet(claimInterface)
|
|
|
|
if !groups.Contains(oauth2Source.RequiredClaimValue) {
|
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
|
}
|
|
}
|
|
}
|
|
|
|
return gothUser, nil
|
|
}
|
|
|
|
func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
|
|
user := &user_model.User{
|
|
LoginName: gothUser.UserID,
|
|
LoginType: auth.OAuth2,
|
|
LoginSource: authSource.ID,
|
|
}
|
|
|
|
hasUser, err := user_model.GetUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if hasUser {
|
|
return user, nil
|
|
}
|
|
log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
|
|
|
|
// search in external linked users
|
|
externalLoginUser := &user_model.ExternalLoginUser{
|
|
ExternalID: gothUser.UserID,
|
|
LoginSourceID: authSource.ID,
|
|
}
|
|
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hasUser {
|
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
|
return user, err
|
|
}
|
|
|
|
// no user found to login
|
|
return nil, nil
|
|
}
|