Go · 4545 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package githttp
4
5 import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "strings"
10 "time"
11
12 "github.com/tenseleyFlow/shithub/internal/auth/pat"
13 "github.com/tenseleyFlow/shithub/internal/auth/password"
14 )
15
16 // resolvedAuth carries the resolved identity for a git-over-HTTPS
17 // request. Anonymous = true is the "no Authorization header" case;
18 // callers decide whether anonymous is allowed (yes for pulling a public
19 // repo, no for everything else).
20 type resolvedAuth struct {
21 Anonymous bool
22 UserID int64
23 Username string
24 ViaPAT bool
25 }
26
27 // errBadCredentials is the catch-all for "creds were sent but didn't
28 // resolve." We DON'T distinguish the failure reasons (wrong username,
29 // wrong password, revoked PAT, etc.) so the response is identical to
30 // "no creds at all" — preventing username probes via timing/messaging.
31 var errBadCredentials = errors.New("githttp: bad credentials")
32
33 // resolveBasicAuth parses the Authorization header and resolves it
34 // against the DB. Three outcomes:
35 //
36 // Anonymous=true, err=nil — no credentials supplied
37 // Anonymous=false, err=nil — credentials matched a real user
38 // Anonymous=false, err!=nil — credentials present but invalid
39 //
40 // PAT path is preferred when the secret carries the canonical
41 // `shithub_pat_` prefix; password path is the fallback. A failed PAT
42 // lookup falls through to password — if a user happens to set their
43 // account password to a string that starts with our PAT prefix, we
44 // still try to authenticate them.
45 func (h *Handlers) resolveBasicAuth(ctx context.Context, header string) (resolvedAuth, error) {
46 if header == "" {
47 return resolvedAuth{Anonymous: true}, nil
48 }
49 scheme, rest, ok := strings.Cut(header, " ")
50 if !ok || !strings.EqualFold(scheme, "Basic") {
51 return resolvedAuth{}, errBadCredentials
52 }
53 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(rest))
54 if err != nil {
55 return resolvedAuth{}, errBadCredentials
56 }
57 user, secret, ok := strings.Cut(string(decoded), ":")
58 if !ok {
59 return resolvedAuth{}, errBadCredentials
60 }
61
62 if strings.HasPrefix(secret, pat.Prefix) {
63 if got, err := h.resolveViaPAT(ctx, secret); err == nil {
64 return got, nil
65 }
66 // Fall through — a non-matching PAT prefix could still be a
67 // user-chosen password.
68 }
69 return h.resolveViaPassword(ctx, user, secret)
70 }
71
72 // resolveViaPAT looks up the token by its sha256 hash, checks
73 // revoked/expired/suspended, and returns the owning user. Returns
74 // errBadCredentials on any failure (no leak about which check failed).
75 func (h *Handlers) resolveViaPAT(ctx context.Context, raw string) (resolvedAuth, error) {
76 hash, err := pat.HashOf(raw)
77 if err != nil {
78 return resolvedAuth{}, errBadCredentials
79 }
80 row, err := h.uq.GetUserTokenByHash(ctx, h.d.Pool, hash)
81 if err != nil {
82 return resolvedAuth{}, errBadCredentials
83 }
84 if row.RevokedAt.Valid {
85 return resolvedAuth{}, errBadCredentials
86 }
87 if row.ExpiresAt.Valid && time.Now().After(row.ExpiresAt.Time) {
88 return resolvedAuth{}, errBadCredentials
89 }
90 user, err := h.uq.GetUserByID(ctx, h.d.Pool, row.UserID)
91 if err != nil || user.SuspendedAt.Valid {
92 return resolvedAuth{}, errBadCredentials
93 }
94 return resolvedAuth{
95 UserID: user.ID, Username: user.Username, ViaPAT: true,
96 }, nil
97 }
98
99 // resolveViaPassword verifies the supplied secret against the user's
100 // argon2id hash. The username supplied in the Basic header is taken at
101 // face value here — git's credential prompt typically asks "Username
102 // for shithub:" and the user types their shithub username.
103 //
104 // Constant-time discipline: when the username doesn't exist we still
105 // run VerifyAgainstDummy so the response time is the same as a wrong
106 // password.
107 func (h *Handlers) resolveViaPassword(ctx context.Context, username, secret string) (resolvedAuth, error) {
108 if username == "" {
109 // No way to look up; still burn time on a dummy to avoid timing leaks.
110 password.VerifyAgainstDummy(secret)
111 return resolvedAuth{}, errBadCredentials
112 }
113 user, err := h.uq.GetUserByUsername(ctx, h.d.Pool, username)
114 if err != nil {
115 password.VerifyAgainstDummy(secret)
116 return resolvedAuth{}, errBadCredentials
117 }
118 if user.SuspendedAt.Valid || user.DeletedAt.Valid {
119 password.VerifyAgainstDummy(secret)
120 return resolvedAuth{}, errBadCredentials
121 }
122 ok, err := password.Verify(secret, user.PasswordHash)
123 if err != nil || !ok {
124 return resolvedAuth{}, errBadCredentials
125 }
126 return resolvedAuth{
127 UserID: user.ID, Username: user.Username, ViaPAT: false,
128 }, nil
129 }
130
131