mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-27 02:14:14 +00:00
7dbad27156
- if `groups` scope provided it checks if all, r:org or r:admin are provided to pass all the groups. otherwise only public memberships - in InfoOAuth it captures scopes from the token if provided in the header. the extraction from the header is maybe a candidate for the separate function so no duplicated code
1423 lines
46 KiB
Go
1423 lines
46 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
go_context "context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models/auth"
|
|
org_model "code.gitea.io/gitea/models/organization"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
auth_module "code.gitea.io/gitea/modules/auth"
|
|
"code.gitea.io/gitea/modules/base"
|
|
"code.gitea.io/gitea/modules/container"
|
|
"code.gitea.io/gitea/modules/json"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/optional"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/modules/web"
|
|
"code.gitea.io/gitea/modules/web/middleware"
|
|
auth_service "code.gitea.io/gitea/services/auth"
|
|
source_service "code.gitea.io/gitea/services/auth/source"
|
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
|
"code.gitea.io/gitea/services/context"
|
|
"code.gitea.io/gitea/services/externalaccount"
|
|
"code.gitea.io/gitea/services/forms"
|
|
remote_service "code.gitea.io/gitea/services/remote"
|
|
user_service "code.gitea.io/gitea/services/user"
|
|
|
|
"gitea.com/go-chi/binding"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/markbates/goth"
|
|
"github.com/markbates/goth/gothic"
|
|
"github.com/markbates/goth/providers/fitbit"
|
|
"github.com/markbates/goth/providers/openidConnect"
|
|
"github.com/markbates/goth/providers/zoom"
|
|
go_oauth2 "golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
tplGrantAccess base.TplName = "user/auth/grant"
|
|
tplGrantError base.TplName = "user/auth/grant_error"
|
|
)
|
|
|
|
// TODO move error and responses to SDK or models
|
|
|
|
// AuthorizeErrorCode represents an error code specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
|
|
type AuthorizeErrorCode string
|
|
|
|
const (
|
|
// ErrorCodeInvalidRequest represents the according error in RFC 6749
|
|
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
|
|
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
|
|
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
|
|
// ErrorCodeAccessDenied represents the according error in RFC 6749
|
|
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
|
|
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
|
|
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
|
|
// ErrorCodeInvalidScope represents the according error in RFC 6749
|
|
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
|
|
// ErrorCodeServerError represents the according error in RFC 6749
|
|
ErrorCodeServerError AuthorizeErrorCode = "server_error"
|
|
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
|
|
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
|
|
)
|
|
|
|
// AuthorizeError represents an error type specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
|
|
type AuthorizeError struct {
|
|
ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
|
|
ErrorDescription string
|
|
State string
|
|
}
|
|
|
|
// Error returns the error message
|
|
func (err AuthorizeError) Error() string {
|
|
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
|
|
}
|
|
|
|
// AccessTokenErrorCode represents an error code specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
type AccessTokenErrorCode string
|
|
|
|
const (
|
|
// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
|
|
// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidClient = "invalid_client"
|
|
// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidGrant = "invalid_grant"
|
|
// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
|
|
// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
|
|
// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
|
|
AccessTokenErrorCodeInvalidScope = "invalid_scope"
|
|
)
|
|
|
|
// AccessTokenError represents an error response specified in RFC 6749
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
type AccessTokenError struct {
|
|
ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
|
|
// Error returns the error message
|
|
func (err AccessTokenError) Error() string {
|
|
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
|
|
}
|
|
|
|
// errCallback represents a oauth2 callback error
|
|
type errCallback struct {
|
|
Code string
|
|
Description string
|
|
}
|
|
|
|
func (err errCallback) Error() string {
|
|
return err.Description
|
|
}
|
|
|
|
// TokenType specifies the kind of token
|
|
type TokenType string
|
|
|
|
const (
|
|
// TokenTypeBearer represents a token type specified in RFC 6749
|
|
TokenTypeBearer TokenType = "bearer"
|
|
// TokenTypeMAC represents a token type specified in RFC 6749
|
|
TokenTypeMAC = "mac"
|
|
)
|
|
|
|
// AccessTokenResponse represents a successful access token response
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
|
|
type AccessTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType TokenType `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
IDToken string `json:"id_token,omitempty"`
|
|
}
|
|
|
|
func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
|
if setting.OAuth2.InvalidateRefreshTokens {
|
|
if err := grant.IncreaseCounter(ctx); err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "cannot increase the grant counter",
|
|
}
|
|
}
|
|
}
|
|
// generate access token to access the API
|
|
expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
|
|
accessToken := &oauth2.Token{
|
|
GrantID: grant.ID,
|
|
Type: oauth2.TypeAccessToken,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
|
|
},
|
|
}
|
|
signedAccessToken, err := accessToken.SignToken(serverKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
|
|
// generate refresh token to request an access token after it expired later
|
|
refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
|
|
refreshToken := &oauth2.Token{
|
|
GrantID: grant.ID,
|
|
Counter: grant.Counter,
|
|
Type: oauth2.TypeRefreshToken,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
|
|
},
|
|
}
|
|
signedRefreshToken, err := refreshToken.SignToken(serverKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
|
|
// generate OpenID Connect id_token
|
|
signedIDToken := ""
|
|
if grant.ScopeContains("openid") {
|
|
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot find application",
|
|
}
|
|
}
|
|
user, err := user_model.GetUserByID(ctx, grant.UserID)
|
|
if err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot find user",
|
|
}
|
|
}
|
|
log.Error("Error loading user: %v", err)
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "server error",
|
|
}
|
|
}
|
|
|
|
idToken := &oauth2.OIDCToken{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
|
|
Issuer: setting.AppURL,
|
|
Audience: []string{app.ClientID},
|
|
Subject: fmt.Sprint(grant.UserID),
|
|
},
|
|
Nonce: grant.Nonce,
|
|
}
|
|
if grant.ScopeContains("profile") {
|
|
idToken.Name = user.GetDisplayName()
|
|
idToken.PreferredUsername = user.Name
|
|
idToken.Profile = user.HTMLURL()
|
|
idToken.Picture = user.AvatarLink(ctx)
|
|
idToken.Website = user.Website
|
|
idToken.Locale = user.Language
|
|
idToken.UpdatedAt = user.UpdatedUnix
|
|
}
|
|
if grant.ScopeContains("email") {
|
|
idToken.Email = user.Email
|
|
idToken.EmailVerified = user.IsActive
|
|
}
|
|
if grant.ScopeContains("groups") {
|
|
onlyPublicGroups := ifOnlyPublicGroups(grant.Scope)
|
|
|
|
groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups)
|
|
if err != nil {
|
|
log.Error("Error getting groups: %v", err)
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "server error",
|
|
}
|
|
}
|
|
idToken.Groups = groups
|
|
}
|
|
|
|
signedIDToken, err = idToken.SignToken(clientKey)
|
|
if err != nil {
|
|
return nil, &AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot sign token",
|
|
}
|
|
}
|
|
}
|
|
|
|
return &AccessTokenResponse{
|
|
AccessToken: signedAccessToken,
|
|
TokenType: TokenTypeBearer,
|
|
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
|
|
RefreshToken: signedRefreshToken,
|
|
IDToken: signedIDToken,
|
|
}, nil
|
|
}
|
|
|
|
type userInfoResponse struct {
|
|
Sub string `json:"sub"`
|
|
Name string `json:"name"`
|
|
Username string `json:"preferred_username"`
|
|
Email string `json:"email"`
|
|
Picture string `json:"picture"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
}
|
|
|
|
func ifOnlyPublicGroups(scopes string) bool {
|
|
scopes = strings.ReplaceAll(scopes, ",", " ")
|
|
scopesList := strings.Fields(scopes)
|
|
for _, scope := range scopesList {
|
|
if scope == "all" || scope == "read:organization" || scope == "read:admin" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// InfoOAuth manages request for userinfo endpoint
|
|
func InfoOAuth(ctx *context.Context) {
|
|
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
|
|
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
|
|
return
|
|
}
|
|
|
|
response := &userInfoResponse{
|
|
Sub: fmt.Sprint(ctx.Doer.ID),
|
|
Name: ctx.Doer.FullName,
|
|
Username: ctx.Doer.Name,
|
|
Email: ctx.Doer.Email,
|
|
Picture: ctx.Doer.AvatarLink(ctx),
|
|
}
|
|
|
|
var token string
|
|
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
|
auths := strings.Fields(auHead)
|
|
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
|
|
token = auths[1]
|
|
}
|
|
}
|
|
|
|
_, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
|
|
onlyPublicGroups := ifOnlyPublicGroups(grantScopes)
|
|
|
|
groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
|
|
if err != nil {
|
|
ctx.ServerError("Oauth groups for user", err)
|
|
return
|
|
}
|
|
response.Groups = groups
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// returns a list of "org" and "org:team" strings,
|
|
// that the given user is a part of.
|
|
func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
|
|
orgs, err := org_model.GetUserOrgsList(ctx, user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
|
}
|
|
|
|
var groups []string
|
|
for _, org := range orgs {
|
|
if setting.OAuth2.EnableAdditionalGrantScopes {
|
|
if onlyPublicGroups {
|
|
public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID)
|
|
if !public && err == nil {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
groups = append(groups, org.Name)
|
|
teams, err := org.LoadTeams(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadTeams: %w", err)
|
|
}
|
|
for _, team := range teams {
|
|
if team.IsMember(ctx, user.ID) {
|
|
groups = append(groups, org.Name+":"+team.LowerName)
|
|
}
|
|
}
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
|
|
authHeader := ctx.Req.Header.Get("Authorization")
|
|
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
|
|
return base.BasicAuthDecode(authData)
|
|
}
|
|
return "", "", errors.New("invalid basic authentication")
|
|
}
|
|
|
|
// IntrospectOAuth introspects an oauth token
|
|
func IntrospectOAuth(ctx *context.Context) {
|
|
clientIDValid := false
|
|
if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
|
|
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
|
|
// this is likely a database error; log it and respond without details
|
|
log.Error("Error retrieving client_id: %v", err)
|
|
ctx.Error(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
|
|
}
|
|
if !clientIDValid {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
|
|
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
|
|
return
|
|
}
|
|
|
|
var response struct {
|
|
Active bool `json:"active"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
|
|
token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
|
|
if err == nil {
|
|
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
|
|
if err == nil && grant != nil {
|
|
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
|
|
if err == nil && app != nil {
|
|
response.Active = true
|
|
response.Scope = grant.Scope
|
|
response.Issuer = setting.AppURL
|
|
response.Audience = []string{app.ClientID}
|
|
response.Subject = fmt.Sprint(grant.UserID)
|
|
}
|
|
if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
|
|
response.Username = user.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// AuthorizeOAuth manages authorize requests
|
|
func AuthorizeOAuth(ctx *context.Context) {
|
|
form := web.GetForm(ctx).(*forms.AuthorizationForm)
|
|
errs := binding.Errors{}
|
|
errs = form.Validate(ctx.Req, errs)
|
|
if len(errs) > 0 {
|
|
errstring := ""
|
|
for _, e := range errs {
|
|
errstring += e.Error() + "\n"
|
|
}
|
|
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
|
|
return
|
|
}
|
|
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
if auth.IsErrOauthClientIDInvalid(err) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "Client ID not registered",
|
|
State: form.State,
|
|
}, "")
|
|
return
|
|
}
|
|
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
|
|
return
|
|
}
|
|
|
|
var user *user_model.User
|
|
if app.UID != 0 {
|
|
user, err = user_model.GetUserByID(ctx, app.UID)
|
|
if err != nil {
|
|
ctx.ServerError("GetUserByID", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !app.ContainsRedirectURI(form.RedirectURI) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "Unregistered Redirect URI",
|
|
State: form.State,
|
|
}, "")
|
|
return
|
|
}
|
|
|
|
if form.ResponseType != "code" {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeUnsupportedResponseType,
|
|
ErrorDescription: "Only code response type is supported.",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
// pkce support
|
|
switch form.CodeChallengeMethod {
|
|
case "S256":
|
|
case "plain":
|
|
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "cannot set code challenge method",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "cannot set code challenge",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
// Here we're just going to try to release the session early
|
|
if err := ctx.Session.Release(); err != nil {
|
|
// we'll tolerate errors here as they *should* get saved elsewhere
|
|
log.Error("Unable to save changes to the session: %v", err)
|
|
}
|
|
case "":
|
|
// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
|
|
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
|
|
if !app.ConfidentialClient {
|
|
// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
|
|
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "PKCE is required for public clients",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
default:
|
|
// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
|
|
// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeInvalidRequest,
|
|
ErrorDescription: "unsupported code challenge method",
|
|
State: form.State,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
|
|
if err != nil {
|
|
handleServerError(ctx, form.State, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
// Redirect if user already granted access and the application is confidential.
|
|
// I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2
|
|
if app.ConfidentialClient && grant != nil {
|
|
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
|
|
if err != nil {
|
|
handleServerError(ctx, form.State, form.RedirectURI)
|
|
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"] = 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.Error(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !form.Granted {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
State: form.State,
|
|
ErrorDescription: "the request is denied",
|
|
ErrorCode: ErrorCodeAccessDenied,
|
|
}, form.RedirectURI)
|
|
return
|
|
}
|
|
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
|
|
return
|
|
}
|
|
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
|
|
if err != nil {
|
|
handleServerError(ctx, form.State, form.RedirectURI)
|
|
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) {
|
|
ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
|
|
ctx.JSONTemplate("user/auth/oidc_wellknown")
|
|
}
|
|
|
|
// OIDCKeys generates the JSON Web Key Set
|
|
func OIDCKeys(ctx *context.Context) {
|
|
jwk, err := oauth2.DefaultSigningKey.ToJWK()
|
|
if err != nil {
|
|
log.Error("Error converting signing key to JWK: %v", err)
|
|
ctx.Error(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jwk["use"] = "sig"
|
|
|
|
jwks := map[string][]map[string]string{
|
|
"keys": {
|
|
jwk,
|
|
},
|
|
}
|
|
|
|
ctx.Resp.Header().Set("Content-Type", "application/json")
|
|
enc := json.NewEncoder(ctx.Resp)
|
|
if err := enc.Encode(jwks); err != nil {
|
|
log.Error("Failed to encode representation as json. Error: %v", err)
|
|
}
|
|
}
|
|
|
|
// AccessTokenOAuth manages all access token requests by the client
|
|
func AccessTokenOAuth(ctx *context.Context) {
|
|
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
|
|
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
|
|
if form.ClientID == "" || form.ClientSecret == "" {
|
|
authHeader := ctx.Req.Header.Get("Authorization")
|
|
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
|
|
clientID, clientSecret, err := base.BasicAuthDecode(authData)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot parse basic auth header",
|
|
})
|
|
return
|
|
}
|
|
// validate that any fields present in the form match the Basic auth header
|
|
if form.ClientID != "" && form.ClientID != clientID {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "client_id in request body inconsistent with Authorization header",
|
|
})
|
|
return
|
|
}
|
|
form.ClientID = clientID
|
|
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
|
|
})
|
|
return
|
|
}
|
|
form.ClientSecret = clientSecret
|
|
}
|
|
}
|
|
|
|
serverKey := oauth2.DefaultSigningKey
|
|
clientKey := serverKey
|
|
if serverKey.IsSymmetric() {
|
|
var err error
|
|
clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "Error creating signing key",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
switch form.GrantType {
|
|
case "refresh_token":
|
|
handleRefreshToken(ctx, form, serverKey, clientKey)
|
|
case "authorization_code":
|
|
handleAuthorizationCode(ctx, form, serverKey, clientKey)
|
|
default:
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
|
|
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
|
|
})
|
|
return
|
|
}
|
|
// "The authorization server MUST ... require client authentication for confidential clients"
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
|
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
|
|
errorDescription := "invalid client secret"
|
|
if form.ClientSecret == "" {
|
|
errorDescription = "invalid empty client secret"
|
|
}
|
|
// "invalid_client ... Client authentication failed"
|
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: errorDescription,
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "unable to parse refresh token",
|
|
})
|
|
return
|
|
}
|
|
// get grant before increasing counter
|
|
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
|
|
if err != nil || grant == nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "grant does not exist",
|
|
})
|
|
return
|
|
}
|
|
|
|
// check if token got already used
|
|
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "token was already used",
|
|
})
|
|
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
|
|
return
|
|
}
|
|
accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
|
|
if tokenErr != nil {
|
|
handleAccessTokenError(ctx, *tokenErr)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, accessToken)
|
|
}
|
|
|
|
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
|
if err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidClient,
|
|
ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
|
|
})
|
|
return
|
|
}
|
|
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
|
|
errorDescription := "invalid client secret"
|
|
if form.ClientSecret == "" {
|
|
errorDescription = "invalid empty client secret"
|
|
}
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: errorDescription,
|
|
})
|
|
return
|
|
}
|
|
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "unexpected redirect URI",
|
|
})
|
|
return
|
|
}
|
|
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
|
|
if err != nil || authorizationCode == nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "client is not authorized",
|
|
})
|
|
return
|
|
}
|
|
// check if code verifier authorizes the client, PKCE support
|
|
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
|
|
ErrorDescription: "failed PKCE code challenge",
|
|
})
|
|
return
|
|
}
|
|
// check if granted for this application
|
|
if authorizationCode.Grant.ApplicationID != app.ID {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidGrant,
|
|
ErrorDescription: "invalid grant",
|
|
})
|
|
return
|
|
}
|
|
// remove token from database to deny duplicate usage
|
|
if err := authorizationCode.Invalidate(ctx); err != nil {
|
|
handleAccessTokenError(ctx, AccessTokenError{
|
|
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
|
ErrorDescription: "cannot proceed your request",
|
|
})
|
|
}
|
|
resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
|
|
if tokenErr != nil {
|
|
handleAccessTokenError(ctx, *tokenErr)
|
|
return
|
|
}
|
|
// send successful response
|
|
ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
|
|
ctx.JSON(http.StatusBadRequest, acErr)
|
|
}
|
|
|
|
func handleServerError(ctx *context.Context, state, redirectURI string) {
|
|
handleAuthorizeError(ctx, AuthorizeError{
|
|
ErrorCode: ErrorCodeServerError,
|
|
ErrorDescription: "A server error occurred",
|
|
State: state,
|
|
}, redirectURI)
|
|
}
|
|
|
|
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
|
|
if redirectURI == "" {
|
|
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
|
|
ctx.Data["Error"] = authErr
|
|
ctx.HTML(http.StatusBadRequest, tplGrantError)
|
|
return
|
|
}
|
|
redirect, err := url.Parse(redirectURI)
|
|
if err != nil {
|
|
ctx.ServerError("url.Parse", err)
|
|
return
|
|
}
|
|
q := redirect.Query()
|
|
q.Set("error", string(authErr.ErrorCode))
|
|
q.Set("error_description", authErr.ErrorDescription)
|
|
q.Set("state", authErr.State)
|
|
redirect.RawQuery = q.Encode()
|
|
ctx.Redirect(redirect.String(), http.StatusSeeOther)
|
|
}
|
|
|
|
// SignInOAuth handles the OAuth2 login buttons
|
|
func SignInOAuth(ctx *context.Context) {
|
|
provider := ctx.Params(":provider")
|
|
|
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
|
if err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
return
|
|
}
|
|
|
|
redirectTo := ctx.FormString("redirect_to")
|
|
if len(redirectTo) > 0 {
|
|
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
|
}
|
|
|
|
// try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
|
|
user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
|
|
if err == nil && user != nil {
|
|
// we got the user without going through the whole OAuth2 authentication flow again
|
|
handleOAuth2SignIn(ctx, authSource, user, gothUser)
|
|
return
|
|
}
|
|
|
|
codeChallenge, err := generateCodeChallenge(ctx, provider)
|
|
if err != nil {
|
|
ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err))
|
|
return
|
|
}
|
|
|
|
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
|
|
if strings.Contains(err.Error(), "no provider for ") {
|
|
if err = oauth2.ResetOAuth2(ctx); err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
return
|
|
}
|
|
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
}
|
|
return
|
|
}
|
|
ctx.ServerError("SignIn", err)
|
|
}
|
|
// redirect is done in oauth2.Auth
|
|
}
|
|
|
|
// SignInOAuthCallback handles the callback from the given provider
|
|
func SignInOAuthCallback(ctx *context.Context) {
|
|
provider := ctx.Params(":provider")
|
|
|
|
if ctx.Req.FormValue("error") != "" {
|
|
var errorKeyValues []string
|
|
for k, vv := range ctx.Req.Form {
|
|
for _, v := range vv {
|
|
errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
|
|
}
|
|
}
|
|
sort.Strings(errorKeyValues)
|
|
ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
|
|
}
|
|
|
|
// first look if the provider is still active
|
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
|
if err != nil {
|
|
ctx.ServerError("SignIn", err)
|
|
return
|
|
}
|
|
|
|
if authSource == nil {
|
|
ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
|
|
return
|
|
}
|
|
|
|
u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
|
|
if err != nil {
|
|
if user_model.IsErrUserProhibitLogin(err) {
|
|
uplerr := err.(user_model.ErrUserProhibitLogin)
|
|
log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
|
|
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
|
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
|
return
|
|
}
|
|
if callbackErr, ok := err.(errCallback); ok {
|
|
log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
|
|
switch callbackErr.Code {
|
|
case "access_denied":
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
|
|
case "temporarily_unavailable":
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
|
|
default:
|
|
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error"))
|
|
}
|
|
ctx.Redirect(setting.AppSubURL + "/user/login")
|
|
return
|
|
}
|
|
if err, ok := err.(*go_oauth2.RetrieveError); ok {
|
|
ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
|
|
}
|
|
ctx.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)
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE
|
|
func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) {
|
|
// the `code_verifier` is only forwarded by specific providers
|
|
// https://codeberg.org/forgejo/forgejo/issues/4033
|
|
p, ok := goth.GetProviders()[provider]
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
switch p.(type) {
|
|
default:
|
|
return "", nil
|
|
case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider:
|
|
// those providers forward the `code_verifier`
|
|
// a code_challenge can be generated
|
|
}
|
|
|
|
codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil {
|
|
return "", err
|
|
}
|
|
return encodeCodeChallenge(codeVerifier)
|
|
}
|
|
|
|
func encodeCodeChallenge(codeVerifier string) (string, error) {
|
|
hasher := sha256.New()
|
|
_, err := io.WriteString(hasher, codeVerifier)
|
|
codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
|
return codeChallenge, err
|
|
}
|
|
|
|
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
|
// login the user
|
|
func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
|
|
gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
|
|
if err != nil {
|
|
return nil, goth.User{}, err
|
|
}
|
|
|
|
if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
|
|
return nil, goth.User{}, err
|
|
}
|
|
|
|
u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
|
|
return u, gothUser, err
|
|
}
|
|
|
|
func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
|
|
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
|
|
|
// Make sure that the response is not an error response.
|
|
errorName := request.FormValue("error")
|
|
|
|
if len(errorName) > 0 {
|
|
errorDescription := request.FormValue("error_description")
|
|
|
|
// Delete the goth session
|
|
err := gothic.Logout(response, request)
|
|
if err != nil {
|
|
return goth.User{}, err
|
|
}
|
|
|
|
return goth.User{}, errCallback{
|
|
Code: errorName,
|
|
Description: errorDescription,
|
|
}
|
|
}
|
|
|
|
// Proceed to authenticate through goth.
|
|
codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string)
|
|
_ = ctx.Session.Delete("CodeVerifier")
|
|
gothUser, err := oauth2Source.Callback(request, response, codeVerifier)
|
|
if err != nil {
|
|
if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
|
|
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
|
err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
|
}
|
|
return goth.User{}, err
|
|
}
|
|
|
|
if oauth2Source.RequiredClaimName != "" {
|
|
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
|
|
if !has {
|
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
|
}
|
|
|
|
if oauth2Source.RequiredClaimValue != "" {
|
|
groups := claimValueToStringSet(claimInterface)
|
|
|
|
if !groups.Contains(oauth2Source.RequiredClaimValue) {
|
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
|
}
|
|
}
|
|
}
|
|
|
|
return gothUser, nil
|
|
}
|
|
|
|
func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
|
|
user := &user_model.User{
|
|
LoginName: gothUser.UserID,
|
|
LoginType: auth.OAuth2,
|
|
LoginSource: authSource.ID,
|
|
}
|
|
|
|
hasUser, err := user_model.GetUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if hasUser {
|
|
return user, nil
|
|
}
|
|
log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
|
|
|
|
// search in external linked users
|
|
externalLoginUser := &user_model.ExternalLoginUser{
|
|
ExternalID: gothUser.UserID,
|
|
LoginSourceID: authSource.ID,
|
|
}
|
|
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hasUser {
|
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
|
return user, err
|
|
}
|
|
|
|
// no user found to login
|
|
return nil, nil
|
|
}
|