authd.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. // Package authd implements the in-house multi-tenant auth IdP that
  2. // backs the M13 admin UI. It issues short-lived access JWTs (HS256,
  3. // 15m by default) and long-lived refresh tokens (opaque random
  4. // strings, 7d, stored server-side with rotation + family re-use
  5. // detection).
  6. //
  7. // The refresh-token side of things is mostly SQL functions
  8. // (migrations/009_auth.up.sql). This package owns:
  9. //
  10. // - JWT signing and verification (HS256, shared secret v1).
  11. // - Magic-link token generation, hashing, and consumption.
  12. // - Password hashing (bcrypt).
  13. // - Server-side session validation for incoming requests
  14. // (used by other services via the VerifyAccessToken call).
  15. // - Audit log writes for auth.* events.
  16. //
  17. // Out of scope for v1 (deferred to v2): JWKS, RS256, SSO mapping,
  18. // MFA, OAuth2 flows. The HS256 shared secret is the only auth
  19. // material — it MUST be rotated before any multi-instance deploy.
  20. //
  21. // Threading: Authd is safe for concurrent use. The store is
  22. // stateless; all state lives in Postgres.
  23. package authd
  24. import (
  25. "context"
  26. "crypto/rand"
  27. "crypto/sha256"
  28. "crypto/subtle"
  29. "encoding/hex"
  30. "errors"
  31. "fmt"
  32. "time"
  33. "github.com/golang-jwt/jwt/v5"
  34. "golang.org/x/crypto/bcrypt"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  36. )
  37. // Config is the authd runtime config. Loaded from env in main.
  38. type Config struct {
  39. // JWTSecret is the HS256 signing key. Must be at least 32 bytes.
  40. // Generated by scripts/gen-jwt-secret.sh on first install.
  41. JWTSecret []byte
  42. // Issuer is the `iss` claim. Should match across all services
  43. // that need to verify tokens.
  44. Issuer string
  45. // AccessTokenTTL is how long access JWTs are valid. 15m default.
  46. AccessTokenTTL time.Duration
  47. // RefreshTokenTTL is how long refresh tokens are valid. 7d default.
  48. RefreshTokenTTL time.Duration
  49. // MagicLinkTTL is how long a magic link is valid. 24h default.
  50. MagicLinkTTL time.Duration
  51. // BcryptCost is the bcrypt work factor. 12 default.
  52. BcryptCost int
  53. }
  54. // DefaultConfig returns Config with safe defaults. JWTSecret is
  55. // zero — main() must load it from env.
  56. func DefaultConfig() Config {
  57. return Config{
  58. Issuer: "broad-announce",
  59. AccessTokenTTL: 15 * time.Minute,
  60. RefreshTokenTTL: 7 * 24 * time.Hour,
  61. MagicLinkTTL: 24 * time.Hour,
  62. BcryptCost: 12,
  63. }
  64. }
  65. // Store returns the underlying Store. Used by HTTP handlers that
  66. // need direct access to user/tenant lookups not on the high-level
  67. // API.
  68. func (a *Authd) Store() *Store { return a.store }
  69. // Authd is the service object. Construct once at startup, pass to
  70. // the HTTP handlers.
  71. type Authd struct {
  72. cfg Config
  73. store *Store
  74. }
  75. // New constructs an Authd. pool is the pgx pool; cfg must have a
  76. // non-zero JWTSecret (caller validates). pool may be nil for
  77. // tests that only exercise the pure-Go paths (bcrypt, JWT); any
  78. // call that hits the DB will return an error in that mode.
  79. func New(pool *postgres.Pool, cfg Config) (*Authd, error) {
  80. if len(cfg.JWTSecret) < 32 {
  81. return nil, fmt.Errorf("authd: JWTSecret must be at least 32 bytes (got %d)", len(cfg.JWTSecret))
  82. }
  83. if cfg.AccessTokenTTL == 0 {
  84. cfg.AccessTokenTTL = 15 * time.Minute
  85. }
  86. if cfg.RefreshTokenTTL == 0 {
  87. cfg.RefreshTokenTTL = 7 * 24 * time.Hour
  88. }
  89. if cfg.MagicLinkTTL == 0 {
  90. cfg.MagicLinkTTL = 24 * time.Hour
  91. }
  92. if cfg.BcryptCost == 0 {
  93. cfg.BcryptCost = 12
  94. }
  95. return &Authd{cfg: cfg, store: NewStore(pool)}, nil
  96. }
  97. // ---------------------------------------------------------------------------
  98. // Errors
  99. // ---------------------------------------------------------------------------
  100. // ErrInvalidCredentials is returned when login fails. The HTTP
  101. // handler maps this to 401 with a generic message — we never
  102. // disclose whether the email or the password was wrong.
  103. var ErrInvalidCredentials = errors.New("authd: invalid credentials")
  104. // ErrUserNotFound is the underlying cause. Handlers should NOT
  105. // surface this to clients.
  106. var ErrUserNotFound = errors.New("authd: user not found")
  107. // ErrUserDisabled is returned when the user exists but is in
  108. // 'pending' (no password set yet, must use magic link) or 'disabled'
  109. // (admin-blocked).
  110. var ErrUserDisabled = errors.New("authd: user not active")
  111. // ErrMagicLinkInvalid is returned for unknown / expired / consumed
  112. // magic links.
  113. var ErrMagicLinkInvalid = errors.New("authd: magic link invalid or expired")
  114. // ErrTokenReuse is returned when a refresh token is used after it's
  115. // been rotated. The whole family has been killed.
  116. var ErrTokenReuse = errors.New("authd: refresh token re-use detected, session killed")
  117. // ---------------------------------------------------------------------------
  118. // Passwords
  119. // ---------------------------------------------------------------------------
  120. // HashPassword returns a bcrypt hash of the plaintext password. Cost
  121. // is taken from cfg.BcryptCost.
  122. func (a *Authd) HashPassword(ctx context.Context, plaintext string) (string, error) {
  123. hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), a.cfg.BcryptCost)
  124. if err != nil {
  125. return "", fmt.Errorf("bcrypt: %w", err)
  126. }
  127. return string(hash), nil
  128. }
  129. // VerifyPassword reports whether plaintext matches the stored hash.
  130. // Returns nil on match, bcrypt.ErrMismatchedHashAndPassword on
  131. // mismatch, or another error if the hash is malformed.
  132. func (a *Authd) VerifyPassword(hash, plaintext string) error {
  133. return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
  134. }
  135. // ---------------------------------------------------------------------------
  136. // Magic links
  137. // ---------------------------------------------------------------------------
  138. // IssueMagicLink generates a magic link token for a user, hashes it,
  139. // stores the hash, and returns the PLAINTEXT token (the caller
  140. // emails this — it is never stored).
  141. //
  142. // Returns the token (hex, 64 chars), its expiry, and any error.
  143. func (a *Authd) IssueMagicLink(ctx context.Context, userID, purpose string) (token string, expiresAt time.Time, err error) {
  144. if purpose != "invite" && purpose != "password_reset" && purpose != "mfa_reset" {
  145. return "", time.Time{}, fmt.Errorf("authd: invalid magic link purpose %q", purpose)
  146. }
  147. // 32 random bytes → 64 hex chars
  148. raw := make([]byte, 32)
  149. if _, err := rand.Read(raw); err != nil {
  150. return "", time.Time{}, fmt.Errorf("rand: %w", err)
  151. }
  152. token = hex.EncodeToString(raw)
  153. hash := sha256.Sum256(raw) // hash the RAW bytes, not the hex string
  154. expiresAt = time.Now().Add(a.cfg.MagicLinkTTL)
  155. if err := a.store.InsertMagicLink(ctx, userID, hash[:], purpose, expiresAt); err != nil {
  156. return "", time.Time{}, err
  157. }
  158. return token, expiresAt, nil
  159. }
  160. // ConsumeMagicLink validates a magic link token and returns the
  161. // associated user_id. Marks the link as consumed. Caller should
  162. // treat ErrMagicLinkInvalid as 401.
  163. func (a *Authd) ConsumeMagicLink(ctx context.Context, token, ip, ua string) (string, error) {
  164. raw, err := hex.DecodeString(token)
  165. if err != nil || len(raw) != 32 {
  166. return "", ErrMagicLinkInvalid
  167. }
  168. hash := sha256.Sum256(raw)
  169. userID, err := a.store.ConsumeMagicLink(ctx, hash[:], ip, ua)
  170. if err != nil {
  171. return "", err
  172. }
  173. return userID, nil
  174. }
  175. // ---------------------------------------------------------------------------
  176. // Sessions (refresh tokens, SQL-side)
  177. // ---------------------------------------------------------------------------
  178. // LoginResult is what Login + MagicLinkConsume return.
  179. type LoginResult struct {
  180. AccessToken string
  181. RefreshToken string
  182. UserID string
  183. TenantID string
  184. Role string
  185. ExpiresAt time.Time
  186. }
  187. // Login authenticates with email+password, returns a fresh session.
  188. // On failure, returns ErrInvalidCredentials (or ErrUserDisabled)
  189. // and writes an audit_log row regardless of outcome.
  190. func (a *Authd) Login(ctx context.Context, email, password, ip, ua string) (*LoginResult, error) {
  191. user, err := a.store.GetUserByEmail(ctx, email)
  192. if err != nil {
  193. if errors.Is(err, ErrUserNotFound) {
  194. // Audit the failed attempt. Don't disclose existence.
  195. _ = a.store.WriteAudit(ctx, "auth.login", "", ip, ua, "", "",
  196. map[string]any{"email": email, "success": false, "reason": "not_found"})
  197. return nil, ErrInvalidCredentials
  198. }
  199. return nil, err
  200. }
  201. if user.Status != "active" {
  202. _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
  203. map[string]any{"email": email, "success": false, "reason": "not_active"})
  204. return nil, ErrUserDisabled
  205. }
  206. if err := a.VerifyPassword(user.PasswordHash, password); err != nil {
  207. _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
  208. map[string]any{"email": email, "success": false, "reason": "bad_password"})
  209. return nil, ErrInvalidCredentials
  210. }
  211. res, err := a.issueSession(ctx, user, ip, ua)
  212. if err != nil {
  213. return nil, err
  214. }
  215. _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
  216. map[string]any{"email": email, "success": true})
  217. _ = a.store.TouchUserLogin(ctx, user.ID)
  218. return res, nil
  219. }
  220. // issueSession is the shared path: mint JWT + ask Postgres for a
  221. // refresh token (via the SQL function).
  222. func (a *Authd) issueSession(ctx context.Context, user *User, ip, ua string) (*LoginResult, error) {
  223. accessJWT, jti, expiresAt, err := a.mintAccessToken(user)
  224. if err != nil {
  225. return nil, err
  226. }
  227. refreshToken, err := a.store.IssueRefreshToken(ctx, user.ID, jti, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
  228. if err != nil {
  229. return nil, err
  230. }
  231. return &LoginResult{
  232. AccessToken: accessJWT,
  233. RefreshToken: refreshToken,
  234. UserID: user.ID,
  235. TenantID: user.TenantID,
  236. Role: user.Role,
  237. ExpiresAt: expiresAt,
  238. }, nil
  239. }
  240. // Refresh swaps a refresh token for a new access+refresh pair. The
  241. // old refresh is revoked; if the old refresh was already consumed,
  242. // the WHOLE family is killed (returns ErrTokenReuse).
  243. func (a *Authd) Refresh(ctx context.Context, presentedToken, ip, ua string) (*LoginResult, error) {
  244. user, newRefresh, newJTI, err := a.store.RotateRefreshToken(ctx, presentedToken, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
  245. if err != nil {
  246. return nil, err
  247. }
  248. accessJWT, expiresAt, err := a.mintAccessTokenWithJTI(user, newJTI)
  249. if err != nil {
  250. return nil, err
  251. }
  252. _ = a.store.WriteAudit(ctx, "auth.refresh", user.ID, ip, ua, "", user.TenantID,
  253. map[string]any{"success": true})
  254. return &LoginResult{
  255. AccessToken: accessJWT,
  256. RefreshToken: newRefresh,
  257. UserID: user.ID,
  258. TenantID: user.TenantID,
  259. Role: user.Role,
  260. ExpiresAt: expiresAt,
  261. }, nil
  262. }
  263. // Logout revokes the refresh token. Idempotent — a missing token
  264. // returns nil (no error) so logout can't be used to probe token
  265. // validity.
  266. func (a *Authd) Logout(ctx context.Context, refreshToken, userID, ip, ua string) error {
  267. if err := a.store.RevokeRefreshToken(ctx, refreshToken); err != nil {
  268. return err
  269. }
  270. _ = a.store.WriteAudit(ctx, "auth.logout", userID, ip, ua, "", "", nil)
  271. return nil
  272. }
  273. // ---------------------------------------------------------------------------
  274. // JWT
  275. // ---------------------------------------------------------------------------
  276. // AccessClaims is what we sign into the access JWT. It carries the
  277. // minimum to authorize a request, NOT a session token.
  278. type AccessClaims struct {
  279. UserID string `json:"sub"`
  280. TenantID string `json:"tid,omitempty"`
  281. Role string `json:"role"`
  282. TokenType string `json:"typ"` // always "access"
  283. jwt.RegisteredClaims
  284. }
  285. // mintAccessToken signs an access JWT for the given user. The JTI
  286. // is generated internally.
  287. func (a *Authd) mintAccessToken(user *User) (token string, jti string, expiresAt time.Time, err error) {
  288. now := time.Now()
  289. expiresAt = now.Add(a.cfg.AccessTokenTTL)
  290. jti = newJTI()
  291. claims := AccessClaims{
  292. UserID: user.ID,
  293. TenantID: user.TenantID,
  294. Role: user.Role,
  295. TokenType: "access",
  296. RegisteredClaims: jwt.RegisteredClaims{
  297. Issuer: a.cfg.Issuer,
  298. Subject: user.ID,
  299. ExpiresAt: jwt.NewNumericDate(expiresAt),
  300. IssuedAt: jwt.NewNumericDate(now),
  301. NotBefore: jwt.NewNumericDate(now),
  302. ID: jti,
  303. },
  304. }
  305. t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
  306. signed, err := t.SignedString(a.cfg.JWTSecret)
  307. if err != nil {
  308. return "", "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
  309. }
  310. return signed, jti, expiresAt, nil
  311. }
  312. // mintAccessTokenWithJTI signs an access JWT using the caller-supplied
  313. // JTI. Used by Refresh so the new refresh's access_jti matches the
  314. // row we just inserted.
  315. func (a *Authd) mintAccessTokenWithJTI(user *User, jti string) (string, time.Time, error) {
  316. now := time.Now()
  317. expiresAt := now.Add(a.cfg.AccessTokenTTL)
  318. claims := AccessClaims{
  319. UserID: user.ID,
  320. TenantID: user.TenantID,
  321. Role: user.Role,
  322. TokenType: "access",
  323. RegisteredClaims: jwt.RegisteredClaims{
  324. Issuer: a.cfg.Issuer,
  325. Subject: user.ID,
  326. ExpiresAt: jwt.NewNumericDate(expiresAt),
  327. IssuedAt: jwt.NewNumericDate(now),
  328. NotBefore: jwt.NewNumericDate(now),
  329. ID: jti,
  330. },
  331. }
  332. t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
  333. signed, err := t.SignedString(a.cfg.JWTSecret)
  334. if err != nil {
  335. return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
  336. }
  337. return signed, expiresAt, nil
  338. }
  339. // VerifyAccessToken parses and validates an access JWT. Returns the
  340. // claims on success. Used by other services that want to authorize
  341. // a request without going through authd.
  342. //
  343. // The signing method is enforced to be HMAC (not 'none', not RS256
  344. // with a confused-deputy attack). The expiry is checked.
  345. func (a *Authd) VerifyAccessToken(raw string) (*AccessClaims, error) {
  346. claims := &AccessClaims{}
  347. tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
  348. // Reject anything that isn't HMAC. See
  349. // https://github.com/golang-jwt/jwt#security-considerations
  350. if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
  351. return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
  352. }
  353. return a.cfg.JWTSecret, nil
  354. })
  355. if err != nil {
  356. return nil, fmt.Errorf("verify jwt: %w", err)
  357. }
  358. if !tok.Valid {
  359. return nil, errors.New("verify jwt: token invalid")
  360. }
  361. if claims.TokenType != "access" {
  362. return nil, fmt.Errorf("verify jwt: wrong token type %q", claims.TokenType)
  363. }
  364. return claims, nil
  365. }
  366. // ---------------------------------------------------------------------------
  367. // Invites
  368. // ---------------------------------------------------------------------------
  369. // InviteUser creates a pending user in a tenant and issues a magic
  370. // link token for the invitation. The token is returned to the
  371. // caller (the HTTP handler) which emails it.
  372. func (a *Authd) InviteUser(ctx context.Context, tenantID, email, role, inviterUserID, ip, ua string) (magicLinkToken string, userID string, err error) {
  373. if role != "tenant_admin" && role != "viewer" {
  374. return "", "", fmt.Errorf("authd: invalid invite role %q (super_admin is not invitable)", role)
  375. }
  376. userID, err = a.store.CreateUser(ctx, tenantID, email, role)
  377. if err != nil {
  378. return "", "", err
  379. }
  380. token, _, err := a.IssueMagicLink(ctx, userID, "invite")
  381. if err != nil {
  382. return "", "", err
  383. }
  384. _ = a.store.WriteAudit(ctx, "auth.invite", inviterUserID, ip, ua, userID, tenantID,
  385. map[string]any{"email": email, "role": role})
  386. return token, userID, nil
  387. }
  388. // SetPassword updates the user's password (bcrypt hash). Used both
  389. // for first-time setup via magic link and for password resets.
  390. func (a *Authd) SetPassword(ctx context.Context, userID, plaintext string) error {
  391. hash, err := a.HashPassword(ctx, plaintext)
  392. if err != nil {
  393. return err
  394. }
  395. return a.store.SetUserPassword(ctx, userID, hash, "active")
  396. }
  397. // ---------------------------------------------------------------------------
  398. // helpers
  399. // ---------------------------------------------------------------------------
  400. // newJTI returns a 128-bit random ID encoded as hex. Used as the
  401. // `jti` claim on access tokens.
  402. func newJTI() string {
  403. var b [16]byte
  404. _, _ = rand.Read(b[:])
  405. return hex.EncodeToString(b[:])
  406. }
  407. // SecureEqual is a constant-time compare. Use it for any byte slice
  408. // equality that could be timing-attacked (e.g. token prefix checks
  409. // in tests, never in prod hot paths where Postgres handles equality).
  410. func SecureEqual(a, b []byte) bool {
  411. return subtle.ConstantTimeCompare(a, b) == 1
  412. }