Files
librenotes/internal/auth/service.go
T
libretech c5f92d6daa
CI / ci (pull_request) Failing after 6m21s
fix(auth): magic link points at /verify.html SPA, not /auth/verify JSON
The link in the magic-link email landed users on the JSON API
endpoint /auth/verify, exposing the raw response body in the
browser. The SPA already ships the wrapper page (verify.html +
verify.js) — it reads the token, POSTs to /auth/verify itself,
saves the JWT, and redirects to /app.html.

Pointing the email at /verify.html restores the intended flow:
operator clicks link → verifying… → 'Signed in as <email>' →
'Go to your notes'.

Verified locally: the LogMailer now emits
http://localhost:18080/verify.html?token=…, GET /verify.html
returns the SPA HTML, and POST /auth/verify still returns the
JWT JSON (unchanged).
2026-04-29 17:12:58 +02:00

153 lines
4.1 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"git.librete.ch/public/librenotes/internal/storage"
)
// nowFn is overridable in tests.
var nowFn = func() time.Time { return time.Now().UTC() }
// Service orchestrates the magic-link login flow: request a link by
// email and verify a returned link to issue a JWT session.
type Service struct {
users *storage.UserStore
tokens *TokenStore
limiter *RateLimiter
mailer Mailer
signer *Signer
baseURL string
dataDir string
}
// Config bundles dependencies for NewService.
type Config struct {
Users *storage.UserStore
Tokens *TokenStore
Limiter *RateLimiter
Mailer Mailer
Signer *Signer
// BaseURL is the public origin used to build verification links,
// e.g. "https://librenot.es".
BaseURL string
// DataDir is the parent directory under which per-user note
// directories are created on first login.
DataDir string
}
// NewService validates and assembles a Service.
func NewService(c Config) (*Service, error) {
if c.Users == nil || c.Tokens == nil || c.Limiter == nil || c.Mailer == nil || c.Signer == nil {
return nil, fmt.Errorf("auth: missing dependency")
}
if c.BaseURL == "" {
return nil, fmt.Errorf("auth: BaseURL required")
}
if c.DataDir == "" {
return nil, fmt.Errorf("auth: DataDir required")
}
return &Service{
users: c.Users,
tokens: c.Tokens,
limiter: c.Limiter,
mailer: c.Mailer,
signer: c.Signer,
baseURL: strings.TrimRight(c.BaseURL, "/"),
dataDir: c.DataDir,
}, nil
}
// RequestLogin generates a magic link and emails it. The user is
// auto-created on first login. Returns ErrRateLimited if the email has
// exceeded the limiter window.
func (s *Service) RequestLogin(ctx context.Context, email string) error {
email = strings.ToLower(strings.TrimSpace(email))
if !looksLikeEmail(email) {
return fmt.Errorf("invalid email")
}
if err := s.limiter.Check(ctx, email); err != nil {
return err
}
user, err := s.users.GetByEmail(ctx, email)
if errors.Is(err, storage.ErrNotFound) {
id := uuid.NewString()
path := filepath.Join(s.dataDir, id)
created, cerr := s.users.Create(ctx, storage.User{
ID: id,
Email: email,
StoragePath: path,
})
if cerr != nil {
return fmt.Errorf("create user: %w", cerr)
}
user = created
} else if err != nil {
return fmt.Errorf("lookup user: %w", err)
}
plaintext, err := s.tokens.Issue(ctx, user.ID, email)
if err != nil {
return err
}
// The magic link must land on the SPA page (verify.html), not the
// JSON API endpoint /auth/verify. The page reads the token from
// the URL, POSTs it to /auth/verify, stores the JWT and redirects
// to /app.html. Pointing the email at /auth/verify exposes the
// raw JSON body to anyone who clicks the link.
link := s.baseURL + "/verify.html?token=" + url.QueryEscape(plaintext)
if err := s.mailer.SendMagicLink(ctx, email, link); err != nil {
return fmt.Errorf("send mail: %w", err)
}
return nil
}
// VerifyResult holds the outcome of a successful magic-link verification.
type VerifyResult struct {
JWT string
User storage.User
}
// Verify consumes a magic-link token and returns a signed session JWT.
func (s *Service) Verify(ctx context.Context, token string) (VerifyResult, error) {
userID, err := s.tokens.Consume(ctx, token)
if err != nil {
return VerifyResult{}, err
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return VerifyResult{}, fmt.Errorf("load user: %w", err)
}
now := nowFn()
if err := s.users.UpdateLastLogin(ctx, user.ID, now); err != nil {
return VerifyResult{}, fmt.Errorf("update last login: %w", err)
}
jwtStr, err := s.signer.Issue(user.ID, user.Email)
if err != nil {
return VerifyResult{}, err
}
return VerifyResult{JWT: jwtStr, User: user}, nil
}
// looksLikeEmail does a minimal sanity check; full validation is left
// to the SMTP server. We just want to reject obvious garbage.
func looksLikeEmail(s string) bool {
at := strings.IndexByte(s, '@')
if at <= 0 || at == len(s)-1 {
return false
}
if strings.ContainsAny(s, " \t\r\n") {
return false
}
return strings.IndexByte(s[at+1:], '.') >= 0
}