Files
backend/internal/app/service.go
Andriy Oblivantsev 18328706bd
CI / test (push) Successful in 5s
Server keys in etc/, bind in docker compose
- bin/gen-server-keys.sh: generate Ed25519 keypair to etc/server-service.{pub,key,env}
- main.go: read keys from file (ADMIN_PUBLIC_KEY_FILE) when env empty
- docker-compose: env_file etc/server-service.env, mount etc/
- bin/up.sh: auto-run gen-server-keys if etc/server-service.env missing
- ErrRegistrationNotConfigured for clearer 503 when keys not set
- etc/README.md, etc/.gitignore
- bin/gen-admin-key.sh for one-off key gen
- .env.example

Made-with: Cursor
2026-03-01 13:02:40 +00:00

327 lines
9.5 KiB
Go

package app
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"momswap/backend/internal/auth"
"momswap/backend/internal/store"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrBadRequest = errors.New("bad request")
ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY")
ErrInviteInvalid = errors.New("invite invalid")
ErrInviteExpired = errors.New("invite expired")
ErrInviteExhaust = errors.New("invite exhausted")
ErrAlreadyUser = errors.New("user already registered")
ErrCollectionMiss = errors.New("collection missing")
ErrFeatureMiss = errors.New("feature missing")
)
type Config struct {
ChallengeTTL time.Duration
SessionTTL time.Duration
}
type Service struct {
store *store.MemoryStore
config Config
servicePublicKey string
}
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
}
type InvitationPayload struct {
JTI string `json:"jti"`
InviterPublicKey string `json:"inviterPublicKey"`
InviteePublicKey string `json:"inviteePublicKey,omitempty"`
ExpiresAtUnix int64 `json:"expiresAtUnix"`
MaxUses int `json:"maxUses"`
}
func (s *Service) BootstrapAdmin(publicKey string) {
if publicKey == "" {
return
}
s.store.UpsertUser(store.User{
PublicKey: publicKey,
CreatedAt: time.Now().UTC(),
})
}
func (s *Service) ServicePublicKey() string {
return s.servicePublicKey
}
func (s *Service) RegisterBySignature(publicKey, signature string) error {
if s.servicePublicKey == "" {
return ErrRegistrationNotConfigured
}
if publicKey == "" {
return fmt.Errorf("%w: missing public key", ErrBadRequest)
}
if _, err := s.store.GetUser(publicKey); err == nil {
return ErrAlreadyUser
}
if err := auth.VerifySignature(publicKey, s.servicePublicKey, signature); err != nil {
return fmt.Errorf("%w: signature verification failed", ErrBadRequest)
}
s.store.UpsertUser(store.User{
PublicKey: publicKey,
CreatedAt: time.Now().UTC(),
})
return nil
}
func (s *Service) CreateChallenge(publicKey string) (string, error) {
if publicKey == "" {
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
}
nonce, err := auth.NewRandomToken(24)
if err != nil {
return "", err
}
err = s.store.CreateChallenge(store.Challenge{
Nonce: nonce,
PublicKey: publicKey,
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
Used: false,
})
if err != nil {
return "", err
}
return nonce, nil
}
func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
ch, err := s.store.GetChallenge(nonce)
if err != nil {
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
}
if ch.Used || time.Now().UTC().After(ch.ExpiresAt) {
return "", fmt.Errorf("%w: challenge expired", ErrUnauthorized)
}
if ch.PublicKey != publicKey {
return "", fmt.Errorf("%w: challenge key mismatch", ErrUnauthorized)
}
if _, err := s.store.GetUser(publicKey); err != nil {
return "", fmt.Errorf("%w: user not registered", ErrUnauthorized)
}
msg := "login:" + nonce
if err := auth.VerifySignature(publicKey, msg, signature); err != nil {
return "", fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
_ = s.store.MarkChallengeUsed(nonce)
token, err := auth.NewRandomToken(32)
if err != nil {
return "", err
}
s.store.SaveSession(store.Session{
Token: token,
PublicKey: publicKey,
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
})
return token, nil
}
func (s *Service) AuthenticateSession(token string) (string, error) {
session, err := s.store.GetSession(token)
if err != nil || time.Now().UTC().After(session.ExpiresAt) {
return "", ErrUnauthorized
}
return session.PublicKey, nil
}
func (s *Service) CreateInvitation(authenticatedKey, invitePayloadB64, inviteSigB64 string) error {
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
if err != nil {
return fmt.Errorf("%w: decode payload", ErrBadRequest)
}
var payload InvitationPayload
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
return fmt.Errorf("%w: invalid payload json", ErrBadRequest)
}
if payload.InviterPublicKey != authenticatedKey {
return fmt.Errorf("%w: inviter mismatch", ErrForbidden)
}
if payload.JTI == "" || payload.MaxUses <= 0 || payload.ExpiresAtUnix <= 0 {
return fmt.Errorf("%w: invalid invite fields", ErrBadRequest)
}
if time.Now().UTC().After(time.Unix(payload.ExpiresAtUnix, 0).UTC()) {
return ErrInviteExpired
}
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
return fmt.Errorf("%w: inviter user missing", ErrForbidden)
}
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
return fmt.Errorf("%w: %v", ErrBadRequest, err)
}
return s.store.SaveInvitation(store.Invitation{
JTI: payload.JTI,
InviterPublicKey: payload.InviterPublicKey,
InviteePublicKey: payload.InviteePublicKey,
ExpiresAt: time.Unix(payload.ExpiresAtUnix, 0).UTC(),
MaxUses: payload.MaxUses,
UsedCount: 0,
})
}
func (s *Service) Register(newPublicKey, invitePayloadB64, inviteSigB64, proofSigB64 string) error {
if newPublicKey == "" {
return fmt.Errorf("%w: missing public key", ErrBadRequest)
}
if _, err := s.store.GetUser(newPublicKey); err == nil {
return ErrAlreadyUser
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
if err != nil {
return fmt.Errorf("%w: decode payload", ErrBadRequest)
}
var payload InvitationPayload
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
return fmt.Errorf("%w: invalid payload", ErrBadRequest)
}
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
return fmt.Errorf("%w: invite signature", ErrInviteInvalid)
}
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
return fmt.Errorf("%w: inviter missing", ErrInviteInvalid)
}
inv, err := s.store.GetInvitation(payload.JTI)
if err != nil {
return fmt.Errorf("%w: invite unknown", ErrInviteInvalid)
}
if inv.InviterPublicKey != payload.InviterPublicKey {
return fmt.Errorf("%w: inviter mismatch", ErrInviteInvalid)
}
if time.Now().UTC().After(inv.ExpiresAt) {
return ErrInviteExpired
}
if inv.UsedCount >= inv.MaxUses {
return ErrInviteExhaust
}
if inv.InviteePublicKey != "" && inv.InviteePublicKey != newPublicKey {
return fmt.Errorf("%w: invite bound to another key", ErrInviteInvalid)
}
proofMessage := "register:" + newPublicKey + ":" + payload.JTI
if err := auth.VerifySignature(newPublicKey, proofMessage, proofSigB64); err != nil {
return fmt.Errorf("%w: register proof", ErrInviteInvalid)
}
if err := s.store.IncrementInvitationUsage(payload.JTI); err != nil {
return err
}
s.store.UpsertUser(store.User{
PublicKey: newPublicKey,
Inviter: payload.InviterPublicKey,
CreatedAt: time.Now().UTC(),
})
return nil
}
func validatePoint(point store.Point) error {
if point.Type != "Point" {
return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest)
}
if len(point.Coordinates) != 2 {
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
}
lon, lat := point.Coordinates[0], point.Coordinates[1]
if lon < -180 || lon > 180 || lat < -90 || lat > 90 {
return fmt.Errorf("%w: invalid lon/lat bounds", ErrBadRequest)
}
return nil
}
func (s *Service) CreateCollection(ownerKey, name string) (store.Collection, error) {
if name == "" {
return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest)
}
id, err := auth.NewRandomToken(12)
if err != nil {
return store.Collection{}, err
}
c := store.Collection{
ID: id,
OwnerKey: ownerKey,
Name: name,
CreatedAt: time.Now().UTC(),
}
s.store.SaveCollection(c)
return c, nil
}
func (s *Service) ListCollections(ownerKey string) []store.Collection {
return s.store.ListCollectionsByOwner(ownerKey)
}
func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) {
collection, err := s.store.GetCollection(collectionID)
if err != nil {
return store.Feature{}, ErrCollectionMiss
}
if collection.OwnerKey != ownerKey {
return store.Feature{}, ErrForbidden
}
if err := validatePoint(geometry); err != nil {
return store.Feature{}, err
}
id, err := auth.NewRandomToken(12)
if err != nil {
return store.Feature{}, err
}
now := time.Now().UTC()
feature := store.Feature{
ID: id,
CollectionID: collectionID,
OwnerKey: ownerKey,
Type: "Feature",
Geometry: geometry,
Properties: properties,
CreatedAt: now,
UpdatedAt: now,
}
s.store.SaveFeature(feature)
return feature, nil
}
func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature, error) {
collection, err := s.store.GetCollection(collectionID)
if err != nil {
return nil, ErrCollectionMiss
}
if collection.OwnerKey != ownerKey {
return nil, ErrForbidden
}
return s.store.ListFeaturesByCollection(collectionID), nil
}
func (s *Service) DeleteFeature(ownerKey, featureID string) error {
feature, err := s.store.GetFeature(featureID)
if err != nil {
return ErrFeatureMiss
}
if feature.OwnerKey != ownerKey {
return ErrForbidden
}
return s.store.DeleteFeature(featureID)
}