@@ -81,7 +81,7 @@ func (s *Service) RegisterBySignature(publicKey, signature string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateChallenge(publicKey string) (string, error) {
|
||||
func (s *Service) CreateChallenge(publicKey, clientIP string) (string, error) {
|
||||
if publicKey == "" {
|
||||
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
|
||||
}
|
||||
@@ -93,6 +93,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) {
|
||||
err = s.store.CreateChallenge(store.Challenge{
|
||||
Nonce: nonce,
|
||||
PublicKey: publicKey,
|
||||
IP: clientIP,
|
||||
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
|
||||
Used: false,
|
||||
})
|
||||
@@ -102,7 +103,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) {
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
|
||||
func (s *Service) Login(publicKey, nonce, signature, clientIP string) (string, error) {
|
||||
ch, err := s.store.GetChallenge(nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
|
||||
@@ -132,6 +133,11 @@ func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
|
||||
PublicKey: publicKey,
|
||||
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
|
||||
})
|
||||
s.store.SaveUserLogin(store.UserLogin{
|
||||
PublicKey: publicKey,
|
||||
IP: clientIP,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func (a *API) createChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
nonce, err := a.service.CreateChallenge(req.PublicKey)
|
||||
nonce, err := a.service.CreateChallenge(req.PublicKey, clientIP(r))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
@@ -163,7 +163,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature)
|
||||
token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature, clientIP(r))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WithRequestLogging wraps h with a handler that logs each request to dir/access.log.
|
||||
// Uses dir "var/logs" by default. Returns h unchanged if dir is empty.
|
||||
func WithRequestLogging(dir string, h http.Handler) (http.Handler, error) {
|
||||
if dir == "" {
|
||||
return h, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "access.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &requestLogger{handler: h, file: f}, nil
|
||||
}
|
||||
|
||||
type requestLogger struct {
|
||||
handler http.Handler
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ip := clientIP(r)
|
||||
method := r.Method
|
||||
path := r.URL.Path
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
if userAgent == "" {
|
||||
userAgent = "-"
|
||||
}
|
||||
|
||||
wrapped := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
l.handler.ServeHTTP(wrapped, r)
|
||||
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
line := formatLogLine(ip, method, path, wrapped.status, elapsed, strings.ReplaceAll(userAgent, "\"", "'"))
|
||||
|
||||
l.mu.Lock()
|
||||
_, _ = l.file.WriteString(line)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func formatLogLine(ip, method, path string, status int, elapsedMs int64, userAgent string) string {
|
||||
// Common log format: ip - - [timestamp] "method path protocol" status size "referer" "user-agent" elapsed_ms
|
||||
t := time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700")
|
||||
return fmt.Sprintf("%s - - [%s] \"%s %s\" %d %d \"-\" \"%s\" %dms\n",
|
||||
ip, t, method, path, status, 0, userAgent, elapsedMs)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// First IP in the list is the original client
|
||||
for i := 0; i < len(xff); i++ {
|
||||
if xff[i] == ',' {
|
||||
return xff[:i]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
host, _ := splitHostPort(r.RemoteAddr)
|
||||
return host
|
||||
}
|
||||
|
||||
func splitHostPort(addr string) (host, port string) {
|
||||
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
|
||||
return addr[:idx], addr[idx+1:]
|
||||
}
|
||||
return addr, ""
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type Store interface {
|
||||
CreateChallenge(ch Challenge) error
|
||||
GetChallenge(nonce string) (Challenge, error)
|
||||
MarkChallengeUsed(nonce string) error
|
||||
SaveUserLogin(ul UserLogin)
|
||||
SaveSession(session Session)
|
||||
GetSession(token string) (Session, error)
|
||||
SaveInvitation(inv Invitation) error
|
||||
|
||||
@@ -230,3 +230,7 @@ func (s *MemoryStore) PruneExpired(now time.Time) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveUserLogin(ul UserLogin) {
|
||||
// In-memory store: no-op for login history (persistence only in Postgres)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
@@ -17,12 +19,20 @@ func Migrate(databaseURL string) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(sql)); err != nil {
|
||||
return err
|
||||
sort.Strings(entries)
|
||||
for _, name := range entries {
|
||||
sql, err := migrationsFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("migrations applied")
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add ip column to challenges (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'challenges' AND column_name = 'ip'
|
||||
) THEN
|
||||
ALTER TABLE challenges ADD COLUMN ip TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- User login history (ip, created_at per user)
|
||||
CREATE TABLE IF NOT EXISTS user_logins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
public_key TEXT NOT NULL REFERENCES users(public_key) ON DELETE CASCADE,
|
||||
ip TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_logins_public_key ON user_logins(public_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_logins_created_at ON user_logins(created_at);
|
||||
@@ -54,8 +54,8 @@ func (s *PostgresStore) GetUser(publicKey string) (User, error) {
|
||||
|
||||
func (s *PostgresStore) CreateChallenge(ch Challenge) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`,
|
||||
ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used,
|
||||
`INSERT INTO challenges (nonce, public_key, ip, expires_at, used) VALUES ($1, $2, $3, $4, $5)`,
|
||||
ch.Nonce, ch.PublicKey, nullStr(ch.IP), ch.ExpiresAt, ch.Used,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -69,9 +69,9 @@ func (s *PostgresStore) CreateChallenge(ch Challenge) error {
|
||||
func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) {
|
||||
var ch Challenge
|
||||
err := s.db.QueryRow(
|
||||
`SELECT nonce, public_key, expires_at, used FROM challenges WHERE nonce = $1`,
|
||||
`SELECT nonce, public_key, COALESCE(ip,''), expires_at, used FROM challenges WHERE nonce = $1`,
|
||||
nonce,
|
||||
).Scan(&ch.Nonce, &ch.PublicKey, &ch.ExpiresAt, &ch.Used)
|
||||
).Scan(&ch.Nonce, &ch.PublicKey, &ch.IP, &ch.ExpiresAt, &ch.Used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Challenge{}, ErrNotFound
|
||||
}
|
||||
@@ -282,6 +282,13 @@ func (s *PostgresStore) DeleteFeature(featureID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveUserLogin(ul UserLogin) {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO user_logins (public_key, ip, created_at) VALUES ($1, $2, $3)`,
|
||||
ul.PublicKey, nullStr(ul.IP), ul.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) PruneExpired(now time.Time) {
|
||||
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
|
||||
|
||||
@@ -11,10 +11,18 @@ type User struct {
|
||||
type Challenge struct {
|
||||
Nonce string
|
||||
PublicKey string
|
||||
IP string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
|
||||
// UserLogin records a successful login for a user (ip, created_at)
|
||||
type UserLogin struct {
|
||||
PublicKey string
|
||||
IP string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
PublicKey string
|
||||
|
||||
Reference in New Issue
Block a user