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