- CORS: rewrite middleware with Vary: Origin, regex origin patterns, startup validation, and prod boot-fail on empty allowlist; shared CORSConfig exported for CSRF reuse - CSRF: new Origin/Referer check middleware sharing CORS allowlist; Bearer-token clients exempt; mounts globally after CORS - Argon2id: new password package with PHC format, bcrypt dispatch, and NeedsRehash; lazy upgrade on login in auth service - Rate limiting: add RateLimitByKey with custom key function; apply per-route limits to /auth/login, /refresh, /2fa/verify, /auth/magic-link, and /auth/password - apierror: add CSRFMismatch and RefreshReuse error constructors - Migrations: 000027 (session model schema columns for D2/D3), 000028 (TOTP secret_v2 column + totp_backup_codes table) - cmd/totp-encrypt: one-shot job to encrypt existing TOTP secrets
98 lines
2.4 KiB
Go
98 lines
2.4 KiB
Go
package apierror
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
type Error struct {
|
|
Status int `json:"-"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return fmt.Sprintf("[%d] %s: %s", e.Status, e.Code, e.Message)
|
|
}
|
|
|
|
type Response struct {
|
|
Error *ErrorBody `json:"error"`
|
|
}
|
|
|
|
type ErrorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func NewResponse(e *Error) Response {
|
|
return Response{
|
|
Error: &ErrorBody{
|
|
Code: e.Code,
|
|
Message: e.Message,
|
|
},
|
|
}
|
|
}
|
|
|
|
func BadRequest(code, message string) *Error {
|
|
return &Error{Status: http.StatusBadRequest, Code: code, Message: message}
|
|
}
|
|
|
|
func Unauthorized(message string) *Error {
|
|
return &Error{Status: http.StatusUnauthorized, Code: "unauthorized", Message: message}
|
|
}
|
|
|
|
func Forbidden(message string) *Error {
|
|
return &Error{Status: http.StatusForbidden, Code: "forbidden", Message: message}
|
|
}
|
|
|
|
func NotFound(resource string) *Error {
|
|
return &Error{
|
|
Status: http.StatusNotFound,
|
|
Code: "not_found",
|
|
Message: fmt.Sprintf("%s not found", resource),
|
|
}
|
|
}
|
|
|
|
func Conflict(message string) *Error {
|
|
return &Error{Status: http.StatusConflict, Code: "conflict", Message: message}
|
|
}
|
|
|
|
// TooManyRequests accepts an optional custom message. Callers that want the
|
|
// default — "too many requests, please try again later" — pass no arguments;
|
|
// callers with context (e.g. "retry in ~60s") pass a single string.
|
|
func TooManyRequests(message ...string) *Error {
|
|
msg := "too many requests, please try again later"
|
|
if len(message) > 0 && message[0] != "" {
|
|
msg = message[0]
|
|
}
|
|
return &Error{
|
|
Status: http.StatusTooManyRequests,
|
|
Code: "rate_limited",
|
|
Message: msg,
|
|
}
|
|
}
|
|
|
|
func Internal(message string) *Error {
|
|
return &Error{
|
|
Status: http.StatusInternalServerError,
|
|
Code: "internal_error",
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
func Validation(message string) *Error {
|
|
return &Error{Status: http.StatusUnprocessableEntity, Code: "validation_error", Message: message}
|
|
}
|
|
|
|
func Gone(message string) *Error {
|
|
return &Error{Status: http.StatusGone, Code: "gone", Message: message}
|
|
}
|
|
|
|
func CSRFMismatch() *Error {
|
|
return &Error{Status: http.StatusForbidden, Code: "auth.csrf_mismatch", Message: "CSRF validation failed"}
|
|
}
|
|
|
|
func RefreshReuse() *Error {
|
|
return &Error{Status: http.StatusUnauthorized, Code: "auth.refresh_reuse_detected", Message: "session token reuse detected"}
|
|
}
|