- Register by signing service key: GET /v1/service-key, POST /v1/auth/register-by-signature - Login auto-attempts register first for new users - Web: default API URL momswap.produktor.duckdns.org, /libs/ static handler - Docker: webbuild stage for geo-api-client, copy web+libs to runtime - Bin scripts: test.sh, run.sh, up.sh, down.sh - docs/ed25519-security-use-cases.md: use cases, message formats, examples - SERVICE_PUBLIC_KEY env (defaults to ADMIN_PUBLIC_KEY) Made-with: Cursor
This commit is contained in:
+29
-4
@@ -29,12 +29,13 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *store.MemoryStore
|
||||
config Config
|
||||
store *store.MemoryStore
|
||||
config Config
|
||||
servicePublicKey string
|
||||
}
|
||||
|
||||
func NewService(memoryStore *store.MemoryStore, cfg Config) *Service {
|
||||
return &Service{store: memoryStore, config: cfg}
|
||||
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
|
||||
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
|
||||
}
|
||||
|
||||
type InvitationPayload struct {
|
||||
@@ -55,6 +56,30 @@ func (s *Service) BootstrapAdmin(publicKey string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ServicePublicKey() string {
|
||||
return s.servicePublicKey
|
||||
}
|
||||
|
||||
func (s *Service) RegisterBySignature(publicKey, signature string) error {
|
||||
if s.servicePublicKey == "" {
|
||||
return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,7 @@ func newTestServer(adminPublicKey string) *httptest.Server {
|
||||
svc := app.NewService(memory, app.Config{
|
||||
ChallengeTTL: 5 * time.Minute,
|
||||
SessionTTL: 24 * time.Hour,
|
||||
})
|
||||
}, adminPublicKey)
|
||||
svc.BootstrapAdmin(adminPublicKey)
|
||||
api := httpapi.NewAPI(svc)
|
||||
return httptest.NewServer(api.Routes())
|
||||
@@ -36,6 +36,22 @@ func mustJSON(t *testing.T, value interface{}) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func getJSON(t *testing.T, client *http.Client, url string, token string) (*http.Response, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]interface{}{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(mustJSON(t, body)))
|
||||
@@ -107,6 +123,46 @@ func registerUserViaAdmin(t *testing.T, client *http.Client, baseURL, adminPub s
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBySignature(t *testing.T) {
|
||||
adminPub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin key: %v", err)
|
||||
}
|
||||
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
|
||||
|
||||
server := newTestServer(adminPubB64)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
|
||||
svcKeyResp, svcKeyData := getJSON(t, client, server.URL+"/v1/service-key", "")
|
||||
if svcKeyResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get service key status=%d body=%v", svcKeyResp.StatusCode, svcKeyData)
|
||||
}
|
||||
if svcKeyData["publicKey"] != adminPubB64 {
|
||||
t.Fatalf("service key mismatch: got %v", svcKeyData["publicKey"])
|
||||
}
|
||||
|
||||
userPub, userPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user key: %v", err)
|
||||
}
|
||||
userPubB64 := base64.RawURLEncoding.EncodeToString(userPub)
|
||||
sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte(adminPubB64)))
|
||||
|
||||
regResp, regData := postJSON(t, client, server.URL+"/v1/auth/register-by-signature", map[string]string{
|
||||
"publicKey": userPubB64,
|
||||
"signature": sig,
|
||||
}, "")
|
||||
if regResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("register-by-signature status=%d body=%v", regResp.StatusCode, regData)
|
||||
}
|
||||
|
||||
userToken := loginUser(t, client, server.URL, userPubB64, userPriv)
|
||||
if userToken == "" {
|
||||
t.Fatal("login after register-by-signature failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoginAndProfile(t *testing.T) {
|
||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,11 +22,14 @@ func NewAPI(svc *app.Service) *API {
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
staticFiles := http.FileServer(http.Dir("web"))
|
||||
libsFiles := http.FileServer(http.Dir("libs"))
|
||||
|
||||
mux.HandleFunc("GET /healthz", a.health)
|
||||
mux.HandleFunc("GET /v1/service-key", a.getServiceKey)
|
||||
mux.HandleFunc("POST /v1/auth/challenge", a.createChallenge)
|
||||
mux.HandleFunc("POST /v1/auth/login", a.login)
|
||||
mux.HandleFunc("POST /v1/auth/register", a.register)
|
||||
mux.HandleFunc("POST /v1/auth/register-by-signature", a.registerBySignature)
|
||||
mux.HandleFunc("POST /v1/invitations", a.createInvitation)
|
||||
mux.HandleFunc("GET /v1/me/keys", a.me)
|
||||
|
||||
@@ -37,6 +40,7 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
||||
|
||||
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
|
||||
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
|
||||
mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect))
|
||||
|
||||
return withCORS(mux)
|
||||
@@ -82,6 +86,8 @@ func statusFromErr(err error) int {
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, app.ErrBadRequest):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, app.ErrAlreadyUser):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, app.ErrInviteInvalid),
|
||||
errors.Is(err, app.ErrInviteExpired),
|
||||
errors.Is(err, app.ErrInviteExhaust):
|
||||
@@ -156,6 +162,15 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"accessToken": token})
|
||||
}
|
||||
|
||||
func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) {
|
||||
pk := a.service.ServicePublicKey()
|
||||
if pk == "" {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
|
||||
}
|
||||
|
||||
func (a *API) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
@@ -174,6 +189,22 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
|
||||
}
|
||||
|
||||
func (a *API) registerBySignature(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.service.RegisterBySignature(req.PublicKey, req.Signature); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
|
||||
}
|
||||
|
||||
func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user