Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions modules/auth/httpauth/httpauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httpauth

import (
"encoding/base64"
"strings"

"code.gitea.io/gitea/modules/util"
)

func ParseAuthorizationHeaderBasic(header string) (string, string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 {
return "", "", false
}
if !util.AsciiEqualFold(parts[0], "basic") {
return "", "", false
}
s, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return "", "", false
}
if u, p, ok := strings.Cut(string(s), ":"); ok {
return u, p, true
}
return "", "", false
}

func ParseAuthorizationHeaderBearerToken(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 {
return "", false
}
if util.AsciiEqualFold(parts[0], "token") || util.AsciiEqualFold(parts[0], "bearer") {
return parts[1], true
}
return "", false
}
56 changes: 56 additions & 0 deletions modules/auth/httpauth/httpauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httpauth

import (
"encoding/base64"
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseAuthorizationHeader(t *testing.T) {
t.Run("Basic", func(t *testing.T) {
cases := []struct {
headerValue string
user, pass string
ok bool
}{
{"", "", "", false},
{"?", "", "", false},
{"foo", "", "", false},
{"Basic ?", "", "", false},
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo")), "", "", false},
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), "foo", "bar", true},
{"basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), "foo", "bar", true},
}
for _, c := range cases {
user, pass, ok := ParseAuthorizationHeaderBasic(c.headerValue)
assert.Equal(t, c.ok, ok, "header %q", c.headerValue)
assert.Equal(t, c.user, user, "header %q", c.headerValue)
assert.Equal(t, c.pass, pass, "header %q", c.headerValue)
}
})
t.Run("BearerToken", func(t *testing.T) {
cases := []struct {
headerValue string
expected string
ok bool
}{
{"", "", false},
{"?", "", false},
{"any value", "", false},
{"token value", "value", true},
{"Token value", "value", true},
{"bearer value", "value", true},
{"Bearer value", "value", true},
{"Bearer wrong value", "", false},
}
for _, c := range cases {
token, ok := ParseAuthorizationHeaderBearerToken(c.headerValue)
assert.Equal(t, c.ok, ok, "header %q", c.headerValue)
assert.Equal(t, c.expected, token, "header %q", c.headerValue)
}
})
}
16 changes: 0 additions & 16 deletions modules/base/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ import (
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"strconv"
"strings"
"time"

"code.gitea.io/gitea/modules/setting"
Expand All @@ -36,19 +33,6 @@ func ShortSha(sha1 string) string {
return util.TruncateRunes(sha1, 10)
}

// BasicAuthDecode decode basic auth string
func BasicAuthDecode(encoded string) (string, string, error) {
s, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", err
}

if username, password, ok := strings.Cut(string(s), ":"); ok {
return username, password, nil
}
return "", "", errors.New("invalid basic authentication")
}

// VerifyTimeLimitCode verify time limit code
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
if len(code) <= 18 {
Expand Down
19 changes: 0 additions & 19 deletions modules/base/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,6 @@ func TestShortSha(t *testing.T) {
assert.Equal(t, "veryverylo", ShortSha("veryverylong"))
}

func TestBasicAuthDecode(t *testing.T) {
_, _, err := BasicAuthDecode("?")
assert.Equal(t, "illegal base64 data at input byte 0", err.Error())

user, pass, err := BasicAuthDecode("Zm9vOmJhcg==")
assert.NoError(t, err)
assert.Equal(t, "foo", user)
assert.Equal(t, "bar", pass)

_, _, err = BasicAuthDecode("aW52YWxpZA==")
assert.Error(t, err)

_, _, err = BasicAuthDecode("invalid")
assert.Error(t, err)

_, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon
assert.Error(t, err)
}

func TestVerifyTimeLimitCode(t *testing.T) {
defer test.MockVariableValue(&setting.InstallLock, true)()
initGeneralSecret := func(secret string) {
Expand Down
21 changes: 21 additions & 0 deletions modules/util/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,24 @@ func SplitTrimSpace(input, sep string) []string {
}
return stringList
}

func asciiLower(b byte) byte {
if 'A' <= b && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}

// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase
if len(s) != len(t) {
return false
}
for i := 0; i < len(s); i++ {
if asciiLower(s[i]) != asciiLower(t[i]) {
return false
}
}
return true
}
27 changes: 8 additions & 19 deletions routers/web/auth/oauth2_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
package auth

import (
"errors"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"

"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
Expand Down Expand Up @@ -108,9 +106,8 @@ func InfoOAuth(ctx *context.Context) {

var accessTokenScope auth.AccessTokenScope
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
auths := strings.Fields(auHead)
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
if headerAuthToken, ok := httpauth.ParseAuthorizationHeaderBearerToken(auHead); ok {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, headerAuthToken)
}
}

Expand All @@ -127,18 +124,11 @@ func InfoOAuth(ctx *context.Context) {
ctx.JSON(http.StatusOK, response)
}

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 {
authHeader := ctx.Req.Header.Get("Authorization")
if clientID, clientSecret, ok := httpauth.ParseAuthorizationHeaderBasic(authHeader); ok {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details
Expand Down Expand Up @@ -465,10 +455,9 @@ 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 {
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
clientID, clientSecret, ok := httpauth.ParseAuthorizationHeaderBasic(authHeader)
if !ok {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
Expand Down
15 changes: 4 additions & 11 deletions services/auth/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ package auth
import (
"errors"
"net/http"
"strings"

actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
Expand Down Expand Up @@ -54,17 +53,11 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
return nil, nil
}

baHead := req.Header.Get("Authorization")
if len(baHead) == 0 {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return nil, nil
}

auths := strings.SplitN(baHead, " ", 2)
if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") {
return nil, nil
}

uname, passwd, _ := base.BasicAuthDecode(auths[1])
uname, passwd, _ := httpauth.ParseAuthorizationHeaderBasic(authHeader)

// Check if username or password is a token
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
Expand Down
6 changes: 2 additions & 4 deletions services/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
Expand Down Expand Up @@ -97,10 +98,7 @@ func parseToken(req *http.Request) (string, bool) {

// check header token
if auHead := req.Header.Get("Authorization"); auHead != "" {
auths := strings.Fields(auHead)
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
return auths[1], true
}
return httpauth.ParseAuthorizationHeaderBearerToken(auHead)
}
return "", false
}
Expand Down
17 changes: 5 additions & 12 deletions services/lfs/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/json"
lfs_module "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
Expand Down Expand Up @@ -594,19 +595,11 @@ func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Rep
if authorization == "" {
return nil, errors.New("no token")
}

parts := strings.SplitN(authorization, " ", 2)
if len(parts) != 2 {
return nil, errors.New("no token")
}
tokenSHA := parts[1]
switch strings.ToLower(parts[0]) {
case "bearer":
fallthrough
case "token":
return handleLFSToken(ctx, tokenSHA, target, mode)
token, ok := httpauth.ParseAuthorizationHeaderBearerToken(authorization)
Copy link
Member

@hiifong hiifong Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref:The Git LFS API uses HTTP Basic Authentication to authorize requests.

I'm not sure, would there be any other issues with us using token?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That "basic" is not this "basic"

if !ok {
return nil, errors.New("token not found")
}
return nil, errors.New("token not found")
return handleLFSToken(ctx, token, target, mode)
}

func requireAuth(ctx *context.Context) {
Expand Down
Loading