feat: initial backend scaffolding

migrating away from convex

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-11-10 00:19:30 +00:00
parent 5cc13a34b2
commit 1feac70f7f
16 changed files with 861 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package auth
import "fmt"
type InvalidAccessTokenError struct {
err error
}
func newInvalidAccessTokenError(err error) *InvalidAccessTokenError {
return &InvalidAccessTokenError{err}
}
func (e *InvalidAccessTokenError) Error() string {
return fmt.Sprintf("invalid access token: %v", e.err)
}
func (e *InvalidAccessTokenError) Unwrap() error {
return e.err
}

View File

@@ -0,0 +1,90 @@
package auth
import (
"errors"
"github.com/gofiber/fiber/v2"
)
const authServiceKey = "authService"
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
type loginResponse struct {
User User `json:"user"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
func RegisterAPIRoutes(api fiber.Router, s *Service) {
auth := api.Group("/auth", func(c *fiber.Ctx) error {
c.Locals(authServiceKey, s)
return c.Next()
})
auth.Post("/login", login)
auth.Post("/register", register)
}
func mustAuthService(c *fiber.Ctx) *Service {
return c.Locals(authServiceKey).(*Service)
}
func login(c *fiber.Ctx) error {
s := mustAuthService(c)
req := new(loginRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
}
result, err := s.LoginWithEmailAndPassword(c.Context(), req.Email, req.Password)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(loginResponse{
User: result.User,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
})
}
func register(c *fiber.Ctx) error {
s := mustAuthService(c)
req := new(registerRequest)
if err := c.BodyParser(req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
}
result, err := s.Register(c.Context(), registerOptions{
email: req.Email,
password: req.Password,
displayName: req.DisplayName,
})
if err != nil {
if errors.Is(err, ErrUserExists) {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(loginResponse{
User: result.User,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
})
}

View File

@@ -0,0 +1,132 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// argon2id parameters
const (
memory = 64 * 1024
iterations = 3
parallelism = 2
saltLength = 16
keyLength = 32
)
var (
ErrInvalidHash = errors.New("invalid hash format")
ErrIncompatibleHash = errors.New("incompatible hash algorithm")
ErrIncompatibleVersion = errors.New("incompatible argon2 version")
)
type argon2Hash struct {
memory uint32
iterations uint32
parallelism uint8
salt []byte
hash []byte
}
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
iterations,
memory,
parallelism,
keyLength,
)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
memory,
iterations,
parallelism,
b64Salt,
b64Hash,
)
return encodedHash, nil
}
func VerifyPassword(password, encodedHash string) (bool, error) {
h, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
otherHash := argon2.IDKey(
[]byte(password),
h.salt,
h.iterations,
h.memory,
h.parallelism,
uint32(len(h.hash)),
)
if subtle.ConstantTimeCompare(h.hash, otherHash) == 1 {
return true, nil
}
return false, nil
}
func decodeHash(encodedHash string) (*argon2Hash, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, ErrInvalidHash
}
if parts[1] != "argon2id" {
return nil, ErrIncompatibleHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return nil, fmt.Errorf("failed to parse version: %w", err)
}
if version != argon2.Version {
return nil, ErrIncompatibleVersion
}
h := &argon2Hash{}
if _, err := fmt.Sscanf(
parts[3],
"m=%d,t=%d,p=%d",
&h.memory,
&h.iterations,
&h.parallelism,
); err != nil {
return nil, fmt.Errorf("failed to parse parameters: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, fmt.Errorf("failed to decode salt: %w", err)
}
h.salt = salt
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, fmt.Errorf("failed to decode hash: %w", err)
}
h.hash = hash
return h, nil
}

View File

@@ -0,0 +1,112 @@
package auth
import (
"context"
"encoding/hex"
"errors"
"github.com/uptrace/bun"
)
type LoginResult struct {
User User
AccessToken string
RefreshToken string
}
var ErrInvalidCredentials = errors.New("invalid credentials")
var ErrUserExists = errors.New("user already exists")
type Service struct {
db *bun.DB
tokenConfig TokenConfig
}
type registerOptions struct {
displayName string
email string
password string
}
func NewService(db *bun.DB, tokenConfig TokenConfig) *Service {
return &Service{
db: db,
tokenConfig: tokenConfig,
}
}
func (s *Service) LoginWithEmailAndPassword(ctx context.Context, email, password string) (*LoginResult, error) {
var user User
err := s.db.NewSelect().Model(&user).Where("email = ?", email).Scan(ctx)
if err != nil {
return nil, err
}
ok, err := VerifyPassword(password, user.Password)
if err != nil || !ok {
return nil, ErrInvalidCredentials
}
at, err := GenerateAccessToken(&user, &s.tokenConfig)
if err != nil {
return nil, err
}
rt, err := GenerateRefreshToken(&user, &s.tokenConfig)
if err != nil {
return nil, err
}
_, err = s.db.NewInsert().Model(rt).Exec(ctx)
if err != nil {
return nil, err
}
return &LoginResult{
User: user,
AccessToken: at,
RefreshToken: hex.EncodeToString(rt.Token),
}, nil
}
func (s *Service) Register(ctx context.Context, opts registerOptions) (*LoginResult, error) {
user := User{
Email: opts.email,
DisplayName: opts.displayName,
}
exists, err := s.db.NewSelect().Model(&user).Where("email = ?", opts.email).Exists(ctx)
if err != nil {
return nil, err
}
if exists {
return nil, ErrUserExists
}
user.Password, err = HashPassword(opts.password)
if err != nil {
return nil, err
}
_, err = s.db.NewInsert().Model(&user).Exec(ctx)
if err != nil {
return nil, err
}
at, err := GenerateAccessToken(&user, &s.tokenConfig)
if err != nil {
return nil, err
}
rt, err := GenerateRefreshToken(&user, &s.tokenConfig)
if err != nil {
return nil, err
}
return &LoginResult{
User: user,
AccessToken: at,
RefreshToken: hex.EncodeToString(rt.Token),
}, nil
}

View File

@@ -0,0 +1,91 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
const (
accessTokenValidFor = time.Minute * 15
refreshTokenByteLength = 32
refreshTokenValidFor = time.Hour * 24 * 30
)
type TokenConfig struct {
Issuer string
Audience string
SecretKey []byte
}
type RefreshToken struct {
bun.BaseModel `bun:"refresh_tokens"`
ID uuid.UUID `bun:",pk,type:uuid"`
UserID uuid.UUID `bun:"user_id,notnull"`
Token []byte `bun:"-"`
TokenHash string `bun:"token_hash,notnull"`
ExpiresAt time.Time `bun:"expires_at,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
}
func GenerateAccessToken(user *User, c *TokenConfig) (string, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: c.Issuer,
Audience: jwt.ClaimStrings{c.Audience},
Subject: user.ID.String(),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenValidFor)),
IssuedAt: jwt.NewNumericDate(now),
})
signed, err := token.SignedString(c.SecretKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return signed, nil
}
func GenerateRefreshToken(user *User, c *TokenConfig) (*RefreshToken, error) {
now := time.Now()
buf := make([]byte, refreshTokenByteLength)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
id, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
h := sha256.Sum256(buf)
hex := hex.EncodeToString(h[:])
return &RefreshToken{
ID: id,
UserID: user.ID,
Token: buf,
TokenHash: hex,
ExpiresAt: now.Add(refreshTokenValidFor),
CreatedAt: now,
}, nil
}
func ParseAccessToken(token string, c *TokenConfig) (*jwt.RegisteredClaims, error) {
parsed, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return c.SecretKey, nil
}, jwt.WithIssuer(c.Issuer), jwt.WithExpirationRequired(), jwt.WithAudience(c.Audience))
if err != nil {
return nil, newInvalidAccessTokenError(err)
}
return parsed.Claims.(*jwt.RegisteredClaims), nil
}

View File

@@ -0,0 +1,17 @@
package auth
import (
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type User struct {
bun.BaseModel `bun:"users"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
DisplayName string `bun:"display_name,notnull" json:"displayName"`
Email string `bun:"email,unique,notnull" json:"email"`
Password string `bun:"password,notnull" json:"-"`
StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes"`
StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes"`
}