Add authentication server, dev CLI, Docker multi-service setup, and cross-platform improvements

This commit is contained in:
Ilya Groshev
2026-04-21 16:49:44 +03:00
parent 43d6527b42
commit a3fbb1aeba
121 changed files with 4523 additions and 2888 deletions
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
)
//go:embed login.html
var loginFS embed.FS
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
type Handlers struct {
store *AuthStore
tok *TokenService
}
func NewHandlers(store *AuthStore, tok *TokenService) *Handlers {
return &Handlers{store: store, tok: tok}
}
type loginPageData struct {
RedirectURI string
State string
Error string
Username string
}
func isOAuthPath(path string) bool {
// Match /v{N}/dialog/oauth or /v{N}.{M}/dialog/oauth
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(parts) != 3 {
return false
}
return strings.HasPrefix(parts[0], "v") && parts[1] == "dialog" && parts[2] == "oauth"
}
func isMePath(path string) bool {
p := strings.TrimPrefix(path, "/")
if p == "me" {
return true
}
parts := strings.Split(p, "/")
return len(parts) == 2 && strings.HasPrefix(parts[0], "v") && parts[1] == "me"
}
func (h *Handlers) HandleOAuth(w http.ResponseWriter, r *http.Request) {
if isMePath(r.URL.Path) {
h.HandleMe(w, r)
return
}
if !isOAuthPath(r.URL.Path) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
h.oauthGet(w, r)
case http.MethodPost:
h.oauthPost(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
data := loginPageData{
RedirectURI: r.URL.Query().Get("redirect_uri"),
State: r.URL.Query().Get("state"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := loginTmpl.Execute(w, data); err != nil {
log.Printf("render login page: %v", err)
}
}
func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
action := r.FormValue("action")
redirectURI := r.FormValue("redirect_uri")
state := r.FormValue("state")
renderErr := func(msg string) {
data := loginPageData{
RedirectURI: redirectURI,
State: state,
Error: msg,
Username: username,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := loginTmpl.Execute(w, data); err != nil {
log.Printf("render login page: %v", err)
}
}
if username == "" || password == "" {
renderErr("Username and password are required.")
return
}
var user AuthUser
var err error
switch action {
case "register":
user, err = h.store.CreateUser(username, password)
if err == ErrUserExists {
renderErr("Username is already taken.")
return
}
if err != nil {
log.Printf("create user: %v", err)
renderErr("Server error. Try again.")
return
}
log.Printf("registered user %q (id=%d)", user.Username, user.ID)
case "login":
user, err = h.store.VerifyUser(username, password)
if err == ErrInvalidCreds {
renderErr("Invalid username or password.")
return
}
if err != nil {
log.Printf("verify user: %v", err)
renderErr("Server error. Try again.")
return
}
log.Printf("authenticated user %q (id=%d)", user.Username, user.ID)
default:
renderErr("Invalid action.")
return
}
token, err := h.tok.Generate(user)
if err != nil {
log.Printf("generate token: %v", err)
renderErr("Server error. Try again.")
return
}
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
fragment := url.Values{}
fragment.Set("access_token", token)
fragment.Set("token_type", "bearer")
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10))
fragment.Set("signed_request", "0."+b64)
if state != "" {
fragment.Set("state", state)
}
target := redirectURI + "?" + fragment.Encode()
log.Printf("redirecting to %s", target)
http.Redirect(w, r, target, http.StatusFound)
}
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.URL.Query().Get("username"))
w.Header().Set("Content-Type", "application/json")
if username == "" {
json.NewEncoder(w).Encode(map[string]bool{"exists": false})
return
}
json.NewEncoder(w).Encode(map[string]bool{"exists": h.store.UserExists(username)})
}
func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("access_token")
if token == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token = auth[7:]
}
}
if token == "" {
http.Error(w, `{"error":{"message":"missing access_token","type":"OAuthException","code":190}}`, http.StatusUnauthorized)
return
}
claims, err := h.tok.Validate(token)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"OAuthException","code":190}}`, err), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"id": strconv.FormatInt(claims.Sub, 10),
"name": claims.Name,
})
}
+199
View File
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lunar Tear Login</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #161616;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 40px 32px 32px;
width: 100%;
max-width: 360px;
}
h1 {
text-align: center;
font-size: 28px;
font-weight: 300;
letter-spacing: 6px;
text-transform: uppercase;
color: #c8c8c8;
margin-bottom: 8px;
}
.subtitle {
text-align: center;
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: #555;
margin-bottom: 32px;
transition: color 0.3s;
}
.error {
background: #2a1515;
border: 1px solid #5a2020;
color: #e08080;
border-radius: 4px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 20px;
}
label {
display: block;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
color: #888;
margin-bottom: 6px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 12px;
background: #0e0e0e;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 15px;
margin-bottom: 18px;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: #666; }
.buttons {
display: flex;
gap: 10px;
margin-top: 8px;
}
button {
flex: 1;
padding: 11px 0;
border: 1px solid #333;
border-radius: 4px;
font-size: 13px;
letter-spacing: 1px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, opacity 0.3s;
}
.btn-login {
background: #e0e0e0;
color: #111;
border-color: #e0e0e0;
}
.btn-login:hover { background: #fff; border-color: #fff; }
.btn-register {
background: transparent;
color: #aaa;
}
.btn-register:hover { border-color: #666; color: #e0e0e0; }
.hidden { display: none; }
@media (max-height: 480px) {
body { align-items: stretch; padding: 0; }
.card {
max-width: none; border-radius: 0; border: none;
min-height: 100vh; min-height: 100dvh;
padding: 20px 24px;
display: flex; flex-direction: column; justify-content: center;
}
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { margin-bottom: 16px; }
input[type="text"],
input[type="password"] { padding: 8px 10px; margin-bottom: 12px; }
.buttons { margin-top: 4px; }
button { padding: 9px 0; }
}
</style>
</head>
<body>
<form class="card" method="POST">
<h1>Lunar Tear</h1>
<div class="subtitle" id="subtitle">Authentication</div>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="hidden" name="state" value="{{.State}}">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<div class="buttons">
<button type="submit" name="action" value="login" class="btn-login hidden" id="btn-login">Login</button>
<button type="submit" name="action" value="register" class="btn-register hidden" id="btn-register">Create Account</button>
</div>
</form>
<script>
(function() {
var input = document.getElementById('username');
var btnLogin = document.getElementById('btn-login');
var btnRegister = document.getElementById('btn-register');
var subtitle = document.getElementById('subtitle');
var timer = null;
var lastChecked = '';
function check() {
var name = input.value.trim();
if (name === '') {
btnLogin.classList.add('hidden');
btnRegister.classList.add('hidden');
subtitle.textContent = 'Authentication';
lastChecked = '';
return;
}
if (name === lastChecked) return;
lastChecked = name;
fetch('/check-username?username=' + encodeURIComponent(name))
.then(function(r) { return r.json(); })
.then(function(data) {
if (input.value.trim() !== name) return;
if (data.exists) {
btnLogin.classList.remove('hidden');
btnRegister.classList.add('hidden');
subtitle.textContent = 'Welcome back';
} else {
btnLogin.classList.add('hidden');
btnRegister.classList.remove('hidden');
subtitle.textContent = 'Create your account';
}
})
.catch(function() {
btnLogin.classList.remove('hidden');
btnRegister.classList.remove('hidden');
subtitle.textContent = 'Authentication';
});
}
input.addEventListener('input', function() {
clearTimeout(timer);
timer = setTimeout(check, 300);
});
input.addEventListener('blur', function() {
clearTimeout(timer);
check();
});
if (input.value.trim() !== '') check();
})();
</script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"flag"
"log"
"net/http"
_ "modernc.org/sqlite"
)
func main() {
listen := flag.String("listen", "0.0.0.0:3000", "HTTP listen address (host:port)")
dbPath := flag.String("db", "db/auth.db", "SQLite database path for auth users")
secret := flag.String("secret", "", "HMAC secret for tokens (auto-generated if empty)")
flag.Parse()
hmacSecret := []byte(*secret)
if len(hmacSecret) == 0 {
hmacSecret = make([]byte, 32)
if _, err := rand.Read(hmacSecret); err != nil {
log.Fatalf("generate secret: %v", err)
}
log.Printf("generated HMAC secret: %s", hex.EncodeToString(hmacSecret))
log.Printf("pass --secret=%s to reuse across restarts", hex.EncodeToString(hmacSecret))
}
db, err := sql.Open("sqlite", *dbPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer db.Close()
store, err := NewAuthStore(db)
if err != nil {
log.Fatalf("init auth store: %v", err)
}
tok := NewTokenService(hmacSecret)
h := NewHandlers(store, tok)
mux := http.NewServeMux()
mux.HandleFunc("/", h.HandleOAuth)
mux.HandleFunc("/me", h.HandleMe)
mux.HandleFunc("/check-username", h.HandleCheckUsername)
log.Printf("auth server listening on %s", *listen)
if err := http.ListenAndServe(*listen, mux); err != nil {
log.Fatalf("listen: %v", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"database/sql"
"errors"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
ErrUserExists = errors.New("username already taken")
ErrInvalidCreds = errors.New("invalid username or password")
)
type AuthUser struct {
ID int64
Username string
}
type AuthStore struct {
db *sql.DB
}
func NewAuthStore(db *sql.DB) (*AuthStore, error) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS auth_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password BLOB NOT NULL,
created_at TEXT NOT NULL
)
`)
if err != nil {
return nil, fmt.Errorf("create auth_users table: %w", err)
}
return &AuthStore{db: db}, nil
}
func (s *AuthStore) CreateUser(username, password string) (AuthUser, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return AuthUser{}, fmt.Errorf("hash password: %w", err)
}
res, err := s.db.Exec(
`INSERT INTO auth_users (username, password, created_at) VALUES (?, ?, ?)`,
username, hash, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return AuthUser{}, ErrUserExists
}
return AuthUser{}, fmt.Errorf("insert user: %w", err)
}
id, _ := res.LastInsertId()
return AuthUser{ID: id, Username: username}, nil
}
func (s *AuthStore) VerifyUser(username, password string) (AuthUser, error) {
var id int64
var hash []byte
err := s.db.QueryRow(
`SELECT id, password FROM auth_users WHERE username = ?`, username,
).Scan(&id, &hash)
if err != nil {
return AuthUser{}, ErrInvalidCreds
}
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
return AuthUser{}, ErrInvalidCreds
}
return AuthUser{ID: id, Username: username}, nil
}
func (s *AuthStore) UserExists(username string) bool {
var n int
err := s.db.QueryRow(`SELECT 1 FROM auth_users WHERE username = ?`, username).Scan(&n)
return err == nil
}
func isUniqueViolation(err error) bool {
return err != nil && (errors.Is(err, sql.ErrNoRows) ||
// modernc.org/sqlite returns error strings containing "UNIQUE constraint failed"
fmt.Sprintf("%v", err) == fmt.Sprintf("%v", err) &&
contains(err.Error(), "UNIQUE constraint failed"))
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
const tokenTTL = 24 * time.Hour
var (
ErrTokenInvalid = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
)
type TokenClaims struct {
Sub int64 `json:"sub"`
Name string `json:"name"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
type TokenService struct {
secret []byte
}
func NewTokenService(secret []byte) *TokenService {
return &TokenService{secret: secret}
}
func (t *TokenService) Generate(user AuthUser) (string, error) {
now := time.Now().Unix()
claims := TokenClaims{
Sub: user.ID,
Name: user.Username,
Iat: now,
Exp: now + int64(tokenTTL.Seconds()),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal claims: %w", err)
}
enc := base64.RawURLEncoding
payloadB64 := enc.EncodeToString(payload)
mac := hmac.New(sha256.New, t.secret)
mac.Write(payload)
sig := enc.EncodeToString(mac.Sum(nil))
return payloadB64 + "." + sig, nil
}
func (t *TokenService) Validate(token string) (TokenClaims, error) {
dot := -1
for i := range token {
if token[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return TokenClaims{}, ErrTokenInvalid
}
payloadB64 := token[:dot]
sigB64 := token[dot+1:]
enc := base64.RawURLEncoding
payload, err := enc.DecodeString(payloadB64)
if err != nil {
return TokenClaims{}, ErrTokenInvalid
}
sig, err := enc.DecodeString(sigB64)
if err != nil {
return TokenClaims{}, ErrTokenInvalid
}
mac := hmac.New(sha256.New, t.secret)
mac.Write(payload)
if !hmac.Equal(mac.Sum(nil), sig) {
return TokenClaims{}, ErrTokenInvalid
}
var claims TokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return TokenClaims{}, ErrTokenInvalid
}
if time.Now().Unix() > claims.Exp {
return TokenClaims{}, ErrTokenExpired
}
return claims, nil
}