CI / ci (pull_request) Failing after 6m21s
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).
153 lines
4.1 KiB
Go
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
|
|
}
|