218 lines
7.1 KiB
Go
218 lines
7.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/mail"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func emailAddress(value string) (string, bool) {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
address, err := mail.ParseAddress(value)
|
|
return value, err == nil && address.Address == value && len(value) <= 254
|
|
}
|
|
func emailBody(w http.ResponseWriter, r *http.Request, input any) bool {
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
|
|
decoder.DisallowUnknownFields()
|
|
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
_ = reject(w, 400, "invalid_request")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) {
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `
|
|
INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes')
|
|
ON CONFLICT(key_hash) DO UPDATE SET
|
|
attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END,
|
|
expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END
|
|
RETURNING attempts
|
|
`, digest(key)).Scan(&count)
|
|
return count <= limit, err
|
|
}
|
|
|
|
func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error {
|
|
if action == "verify-email" {
|
|
return s.verifyEmail(w, r, project)
|
|
}
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !emailBody(w, r, &input) {
|
|
return nil
|
|
}
|
|
email, valid := emailAddress(input.Email)
|
|
if !valid || len(input.Password) < 12 || len(input.Password) > 72 {
|
|
return reject(w, 400, "invalid_credentials_format")
|
|
}
|
|
limit := 10
|
|
if action == "register" {
|
|
if s.email == nil {
|
|
return reject(w, 503, "email_not_configured")
|
|
}
|
|
limit = 3
|
|
}
|
|
allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
w.Header().Set("Retry-After", "900")
|
|
return reject(w, 429, "rate_limited")
|
|
}
|
|
select {
|
|
case s.passwordSlots <- struct{}{}:
|
|
defer func() { <-s.passwordSlots }()
|
|
default:
|
|
w.Header().Set("Retry-After", "2")
|
|
return reject(w, 429, "auth_busy")
|
|
}
|
|
if action == "register" {
|
|
return s.registerEmail(w, r, project, email, input.Password)
|
|
}
|
|
var account, hash string
|
|
err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash)
|
|
missing := errors.Is(err, sql.ErrNoRows)
|
|
if err != nil && !missing {
|
|
return err
|
|
}
|
|
passwordHash := []byte(hash)
|
|
if missing {
|
|
passwordHash = s.dummyPassword
|
|
}
|
|
if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing {
|
|
return reject(w, 401, "invalid_credentials")
|
|
}
|
|
session := token()
|
|
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
|
return nil
|
|
}
|
|
|
|
func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
|
|
return err
|
|
}
|
|
verification := token()
|
|
result, err := tx.ExecContext(r.Context(), `
|
|
INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at)
|
|
SELECT $1,$2,$3,$4,now()+interval '30 minutes'
|
|
WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2)
|
|
ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at
|
|
`, project, email, string(hash), digest(verification))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
destination := *s.projects[project]
|
|
destination.Fragment = "verify_email=" + verification
|
|
body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email."
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes')
|
|
`, project, email, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
reply(w, 202, payload{"status": "verification_requested"})
|
|
return nil
|
|
}
|
|
|
|
func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error {
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if !emailBody(w, r, &input) {
|
|
return nil
|
|
}
|
|
if !tokenPattern.MatchString(input.Token) {
|
|
return reject(w, 400, "invalid_verification")
|
|
}
|
|
allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
return reject(w, 429, "rate_limited")
|
|
}
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var email, hash string
|
|
err = tx.QueryRowContext(r.Context(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return reject(w, 400, "invalid_verification")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
|
|
return err
|
|
}
|
|
err = tx.QueryRowContext(r.Context(), `
|
|
DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash
|
|
`, project, digest(input.Token)).Scan(&email, &hash)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return reject(w, 400, "invalid_verification")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var account string
|
|
err = tx.QueryRowContext(r.Context(), `
|
|
WITH identity AS(SELECT gen_random_uuid() AS id)
|
|
INSERT INTO project_accounts(project,id,email,username)
|
|
SELECT $1,id,$2,
|
|
left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7)
|
|
|| '_' || replace(id::text,'-','') FROM identity RETURNING id::text
|
|
`, project, email).Scan(&account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
session := token()
|
|
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
|
return nil
|
|
}
|