Go · 5635 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package web
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "os"
13 "path/filepath"
14 "time"
15
16 "github.com/jackc/pgx/v5/pgxpool"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/audit"
19 "github.com/tenseleyFlow/shithub/internal/auth/email"
20 "github.com/tenseleyFlow/shithub/internal/auth/password"
21 "github.com/tenseleyFlow/shithub/internal/auth/pat"
22 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
23 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
24 "github.com/tenseleyFlow/shithub/internal/auth/session"
25 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
26 "github.com/tenseleyFlow/shithub/internal/infra/config"
27 "github.com/tenseleyFlow/shithub/internal/infra/storage"
28 "github.com/tenseleyFlow/shithub/internal/ratelimit"
29 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
30 apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
31 "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
32 authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
33 "github.com/tenseleyFlow/shithub/internal/web/middleware"
34 "github.com/tenseleyFlow/shithub/internal/web/render"
35 )
36
37 // sharedPATDebouncer is used by both the PATAuth middleware (in the API
38 // route group) and any future paths that want to coordinate last-used
39 // updates across handlers in the same process.
40 var sharedPATDebouncer = pat.NewDebouncer(0)
41
42 // buildAPIHandlers wires the PAT-authenticated API surface.
43 func buildAPIHandlers(
44 cfg config.Config,
45 pool *pgxpool.Pool,
46 objectStore storage.ObjectStore,
47 runnerJWT *runnerjwt.Signer,
48 secretBox *secretbox.Box,
49 rateLimiter *ratelimit.Limiter,
50 logger *slog.Logger,
51 ) (*apih.Handlers, error) {
52 if cfg.Storage.ReposRoot == "" {
53 return nil, errors.New("api: cfg.Storage.ReposRoot is empty")
54 }
55 root, err := filepath.Abs(cfg.Storage.ReposRoot)
56 if err != nil {
57 return nil, fmt.Errorf("api: resolve repos_root: %w", err)
58 }
59 rfs, err := storage.NewRepoFS(root)
60 if err != nil {
61 return nil, fmt.Errorf("api: NewRepoFS: %w", err)
62 }
63 return apih.New(apih.Deps{
64 Pool: pool,
65 Debouncer: sharedPATDebouncer,
66 Logger: logger,
67 ObjectStore: objectStore,
68 RepoFS: rfs,
69 RunnerJWT: runnerJWT,
70 SecretBox: secretBox,
71 RateLimiter: rateLimiter,
72 BaseURL: cfg.Auth.BaseURL,
73 APILimit: apilimit.Config{
74 AuthedPerHour: cfg.RateLimit.API.AuthedPerHour,
75 AnonPerHour: cfg.RateLimit.API.AnonPerHour,
76 Logger: logger,
77 },
78 })
79 }
80
81 // usernameLookup returns the lookup function consumed by middleware.OptionalUser.
82 // It resolves the username, the user's current session_epoch (so the auth
83 // middleware can refuse stale cookies bumped by "log out everywhere"), and
84 // the suspended state (so policy.UserActor can deny writes by suspended
85 // accounts at the web layer — the audit found this gap, S00-S25 audit C1).
86 func usernameLookup(pool *pgxpool.Pool) middleware.UserLookup {
87 q := usersdb.New()
88 return func(ctx context.Context, id int64) (middleware.UserLookupResult, error) {
89 u, err := q.GetUserByID(ctx, pool, id)
90 if err != nil {
91 return middleware.UserLookupResult{}, err
92 }
93 return middleware.UserLookupResult{
94 Username: u.Username,
95 SessionEpoch: u.SessionEpoch,
96 IsSuspended: u.SuspendedAt.Valid,
97 IsSiteAdmin: u.IsSiteAdmin,
98 }, nil
99 }
100 }
101
102 // buildAuthHandlers wires the auth surface from the loaded config and
103 // the bootstrapped DB / session / logger / templates. Selecting the email
104 // backend is config-driven (`auth.email_backend`).
105 func buildAuthHandlers(
106 cfg config.Config,
107 pool *pgxpool.Pool,
108 store session.Store,
109 objectStore storage.ObjectStore,
110 logger *slog.Logger,
111 tmplFS fs.FS,
112 ) (*authh.Handlers, error) {
113 rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
114 if err != nil {
115 return nil, err
116 }
117 sender, err := pickEmailSender(cfg)
118 if err != nil {
119 return nil, err
120 }
121 var box *secretbox.Box
122 if cfg.Auth.TOTPKeyB64 != "" {
123 b, err := secretbox.FromBase64(cfg.Auth.TOTPKeyB64)
124 if err != nil {
125 return nil, err
126 }
127 box = b
128 } else {
129 logger.Warn("auth: no totp_key_b64 configured; 2FA enrollment routes disabled",
130 "hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable 2FA")
131 }
132 return authh.New(authh.Deps{
133 Logger: logger,
134 Render: rr,
135 Pool: pool,
136 SessionStore: store,
137 Email: sender,
138 Branding: email.Branding{
139 SiteName: cfg.Auth.SiteName,
140 BaseURL: cfg.Auth.BaseURL,
141 From: cfg.Auth.EmailFrom,
142 },
143 Argon2: password.Params{
144 Memory: cfg.Auth.Argon2.MemoryKiB,
145 Time: cfg.Auth.Argon2.Time,
146 Threads: cfg.Auth.Argon2.Threads,
147 SaltLen: 16,
148 KeyLen: 32,
149 },
150 Limiter: throttle.NewLimiter(),
151 RateLimiter: ratelimit.New(pool),
152 RequireEmailVerification: cfg.Auth.RequireEmailVerification,
153 SecretBox: box,
154 Audit: audit.NewRecorder(),
155 ObjectStore: objectStore,
156 })
157 }
158
159 func pickEmailSender(cfg config.Config) (email.Sender, error) {
160 switch cfg.Auth.EmailBackend {
161 case "stdout":
162 return email.NewStdoutSender(os.Stdout), nil
163 case "smtp":
164 return &email.SMTPSender{
165 Addr: cfg.Auth.SMTP.Addr,
166 From: cfg.Auth.EmailFrom,
167 Username: cfg.Auth.SMTP.Username,
168 Password: cfg.Auth.SMTP.Password,
169 }, nil
170 case "postmark":
171 return &email.PostmarkSender{
172 ServerToken: cfg.Auth.Postmark.ServerToken,
173 From: cfg.Auth.EmailFrom,
174 HTTP: &http.Client{Timeout: 10 * time.Second},
175 }, nil
176 default:
177 return nil, errors.New("auth: unknown email_backend")
178 }
179 }
180