tenseleyflow/shithub / cd1c278

Browse files

S12: githttp Handlers struct + HTTP Basic auth resolver (PAT-prefix preferred, password fallback)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cd1c27821fac60abc696595aa19321709fcc2b0d
Parents
e860cb1
Tree
d730412

2 changed files

StatusFile+-
A internal/web/handlers/githttp/auth.go 130 0
A internal/web/handlers/githttp/githttp.go 54 0
internal/web/handlers/githttp/auth.goadded
@@ -0,0 +1,130 @@
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
+
internal/web/handlers/githttp/githttp.goadded
@@ -0,0 +1,54 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package githttp wires the smart-HTTP git protocol routes:
4
+// info/refs + git-upload-pack + git-receive-pack. Each request shells
5
+// out to canonical `git` plumbing — we don't reimplement the protocol.
6
+//
7
+// This file owns the handler struct + Deps + Mounting; the route bodies
8
+// live in handler_*.go and the credential resolver lives in auth.go.
9
+package githttp
10
+
11
+import (
12
+	"errors"
13
+	"log/slog"
14
+
15
+	"github.com/jackc/pgx/v5/pgxpool"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20
+)
21
+
22
+// Deps wires the git-HTTP handler set.
23
+type Deps struct {
24
+	Logger *slog.Logger
25
+	Pool   *pgxpool.Pool
26
+	RepoFS *storage.RepoFS
27
+	// MaxPushBytes is the hard cap on the git-receive-pack request body.
28
+	// Defaults to 2 GiB when zero.
29
+	MaxPushBytes int64
30
+}
31
+
32
+// Handlers is the registered handler set. Construct via New.
33
+type Handlers struct {
34
+	d  Deps
35
+	uq *usersdb.Queries
36
+	rq *reposdb.Queries
37
+}
38
+
39
+// DefaultMaxPushBytes is the spec-recommended cap.
40
+const DefaultMaxPushBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GiB
41
+
42
+// New constructs the handler set, validating Deps.
43
+func New(d Deps) (*Handlers, error) {
44
+	if d.Pool == nil {
45
+		return nil, errors.New("githttp: nil Pool")
46
+	}
47
+	if d.RepoFS == nil {
48
+		return nil, errors.New("githttp: nil RepoFS")
49
+	}
50
+	if d.MaxPushBytes == 0 {
51
+		d.MaxPushBytes = DefaultMaxPushBytes
52
+	}
53
+	return &Handlers{d: d, uq: usersdb.New(), rq: reposdb.New()}, nil
54
+}