326 lines
9.5 KiB
Go
326 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte("Verstak Sync Server\n"))
|
|
return
|
|
}
|
|
jsonErr(w, 404, "not found")
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
jsonOK(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"version": "verstak-server/v1",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
jsonErr(w, 405, "POST required")
|
|
return
|
|
}
|
|
ip := r.RemoteAddr
|
|
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
|
|
ip = ip[:idx]
|
|
}
|
|
if !s.pairLimit.allow(ip) {
|
|
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
|
|
jsonErr(w, 429, "too many attempts")
|
|
return
|
|
}
|
|
var req struct {
|
|
Login string `json:"login"`
|
|
Password string `json:"password"`
|
|
DeviceName string `json:"device_name"`
|
|
ClientVersion string `json:"client_version"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
if req.Login == "" || req.Password == "" {
|
|
jsonErr(w, 400, "login and password required")
|
|
return
|
|
}
|
|
if req.DeviceName == "" {
|
|
req.DeviceName = "unknown"
|
|
}
|
|
// Look up user.
|
|
var userID, hash string
|
|
var confirmed, blocked int
|
|
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
if err != nil {
|
|
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
if blocked != 0 {
|
|
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
|
|
jsonErr(w, 403, "account blocked")
|
|
return
|
|
}
|
|
if confirmed == 0 {
|
|
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
|
|
jsonErr(w, 403, "email not confirmed")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
// Generate device.
|
|
devID := make([]byte, 12)
|
|
rand.Read(devID)
|
|
deviceID := "dev_" + hex.EncodeToString(devID)
|
|
token, prefix, suffix := genDeviceToken()
|
|
tokenHash := sha256Hex(token)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
apiKey := make([]byte, 20)
|
|
rand.Read(apiKey)
|
|
_, err = s.db.Exec(`INSERT INTO server_devices
|
|
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
|
|
userID, req.ClientVersion, ip, now, now)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
|
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
|
|
s.pairLimit.reset(ip)
|
|
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
|
|
jsonOK(w, map[string]interface{}{
|
|
"user_id": userID,
|
|
"device_id": deviceID,
|
|
"device_token": token,
|
|
"server_time": now,
|
|
"initial_sync_cursor": 0,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
jsonErr(w, 405, "POST required")
|
|
return
|
|
}
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == "" {
|
|
jsonErr(w, 400, "username and password required")
|
|
return
|
|
}
|
|
var hash string
|
|
var confirmed, blocked int
|
|
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
|
|
if err != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
if blocked != 0 {
|
|
jsonErr(w, 403, "account blocked")
|
|
return
|
|
}
|
|
if confirmed == 0 {
|
|
jsonErr(w, 403, "email not confirmed")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
jsonErr(w, 405, "POST required")
|
|
return
|
|
}
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if tok == "" {
|
|
jsonErr(w, 401, "token required")
|
|
return
|
|
}
|
|
hash := sha256Hex(tok)
|
|
var deviceID, userID string
|
|
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
|
|
if err != nil {
|
|
jsonErr(w, 401, "invalid token")
|
|
return
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
|
|
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
|
|
jsonOK(w, map[string]string{"status": "revoked"})
|
|
}
|
|
|
|
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
jsonErr(w, 405, "POST required")
|
|
return
|
|
}
|
|
userID, ok := s.requireUserWeb(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
DeviceID string `json:"device_id"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "invalid JSON")
|
|
return
|
|
}
|
|
if req.DeviceID == "" || req.Password == "" {
|
|
jsonErr(w, 400, "device_id and password required")
|
|
return
|
|
}
|
|
// Verify password.
|
|
var pwHash string
|
|
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
|
|
if err != nil {
|
|
jsonErr(w, 403, "access denied")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
|
|
jsonErr(w, 403, "wrong password")
|
|
return
|
|
}
|
|
// Verify device belongs to user.
|
|
var devUserID string
|
|
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
|
|
if err != nil {
|
|
jsonErr(w, 404, "device not found")
|
|
return
|
|
}
|
|
if devUserID != userID {
|
|
jsonErr(w, 403, "device does not belong to you")
|
|
return
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
|
|
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
|
|
jsonOK(w, map[string]string{"status": "revoked"})
|
|
}
|
|
|
|
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if tok == "" {
|
|
jsonErr(w, 401, "token required")
|
|
return
|
|
}
|
|
hash := sha256Hex(tok)
|
|
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
|
|
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
|
FROM server_devices d WHERE d.token_hash=?`, hash).
|
|
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
|
if err != nil {
|
|
jsonErr(w, 401, "invalid token")
|
|
return
|
|
}
|
|
var username string
|
|
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
|
jsonOK(w, map[string]interface{}{
|
|
"device_id": deviceID,
|
|
"user_id": userID,
|
|
"username": username,
|
|
"device_name": name,
|
|
"client_version": clientVer,
|
|
"last_seen": lastSeen,
|
|
"revoked_at": revokedAt,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
jsonErr(w, 405, "POST required")
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
jsonErr(w, 400, "name required")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == "" {
|
|
jsonErr(w, 401, "username and password required")
|
|
return
|
|
}
|
|
|
|
// Look up user by username or email.
|
|
var userID, hash string
|
|
var confirmed, blocked int
|
|
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
if err != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
if blocked != 0 {
|
|
jsonErr(w, 403, "account blocked")
|
|
return
|
|
}
|
|
if confirmed == 0 {
|
|
jsonErr(w, 403, "email not confirmed")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
b := make([]byte, 20)
|
|
rand.Read(b)
|
|
apiKey := hex.EncodeToString(b)
|
|
deviceID := apiKey[:12]
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
_, err = s.db.Exec(
|
|
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
deviceID, req.Name, apiKey, now, now,
|
|
)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// Link device to user.
|
|
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
|
|
|
jsonOK(w, map[string]interface{}{
|
|
"device_id": deviceID,
|
|
"api_key": apiKey,
|
|
})
|
|
}
|