14.04.2026 Update
This commit is contained in:
484
auth/auth.go
Normal file
484
auth/auth.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionTTL = 8 * time.Hour
|
||||
cookieName = "ar_session"
|
||||
nonceExpiry = 2 * time.Minute
|
||||
maxNonces = 256
|
||||
rotateInterval = 10 * time.Second
|
||||
rotateGrace = 10 * time.Second
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
passwordHashInner = 10000
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
CurrentToken string
|
||||
PrevToken string
|
||||
PrevRotatedAt time.Time
|
||||
IP string
|
||||
CreatedAt time.Time
|
||||
LastSeen time.Time
|
||||
LastRotate time.Time
|
||||
}
|
||||
|
||||
type Challenge struct {
|
||||
Nonce string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
nonces map[string]*Challenge
|
||||
|
||||
muConfig sync.RWMutex
|
||||
username string
|
||||
password string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
var Global = &Store{
|
||||
sessions: make(map[string]*Session),
|
||||
nonces: make(map[string]*Challenge),
|
||||
}
|
||||
|
||||
func (s *Store) Init(username, passwordHash, apiKey string) {
|
||||
s.muConfig.Lock()
|
||||
defer s.muConfig.Unlock()
|
||||
if username != "" {
|
||||
s.username = username
|
||||
} else {
|
||||
s.username = defaultUsername
|
||||
}
|
||||
if passwordHash != "" {
|
||||
s.password = passwordHash
|
||||
} else {
|
||||
s.password = HashPassword(defaultPassword)
|
||||
}
|
||||
s.apiKey = apiKey
|
||||
}
|
||||
|
||||
func (s *Store) IsDefaultPassword() bool {
|
||||
s.muConfig.RLock()
|
||||
defer s.muConfig.RUnlock()
|
||||
return s.password == HashPassword(defaultPassword)
|
||||
}
|
||||
|
||||
func (s *Store) GetCredentials() (username, apiKey string, isDefault bool) {
|
||||
s.muConfig.RLock()
|
||||
defer s.muConfig.RUnlock()
|
||||
return s.username, s.apiKey, s.password == HashPassword(defaultPassword)
|
||||
}
|
||||
|
||||
func HashPassword(password string) string {
|
||||
salt := "nano-router-salt-v1"
|
||||
for i := 0; i < passwordHashInner; i++ {
|
||||
data := salt + password + strconv.Itoa(i)
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
salt = hex.EncodeToString(sum[:16])
|
||||
}
|
||||
final := sha256.Sum256([]byte(salt + password))
|
||||
return hex.EncodeToString(final[:])
|
||||
}
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Store) CreateChallenge() string {
|
||||
nonce := generateToken()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.nonces) > maxNonces {
|
||||
now := time.Now()
|
||||
for k, v := range s.nonces {
|
||||
if now.Sub(v.CreatedAt) > nonceExpiry {
|
||||
delete(s.nonces, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.nonces[nonce] = &Challenge{Nonce: nonce, CreatedAt: time.Now()}
|
||||
return nonce
|
||||
}
|
||||
|
||||
func (s *Store) ValidateChallenge(nonce string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.nonces[nonce]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.nonces, nonce)
|
||||
return time.Since(ch.CreatedAt) < nonceExpiry
|
||||
}
|
||||
|
||||
func ComputeResponse(nonce, passwordHash string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(nonce))
|
||||
h.Write([]byte(":"))
|
||||
h.Write([]byte(passwordHash))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Store) Login(nonce, response, clientIP string) (sessionID string, ok bool) {
|
||||
if !s.ValidateChallenge(nonce) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s.muConfig.RLock()
|
||||
defer s.muConfig.RUnlock()
|
||||
|
||||
expected := ComputeResponse(nonce, s.password)
|
||||
if subtle.ConstantTimeCompare([]byte(response), []byte(expected)) != 1 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
sessionID = generateToken()
|
||||
session := &Session{
|
||||
CurrentToken: sessionID,
|
||||
IP: clientIP,
|
||||
CreatedAt: time.Now(),
|
||||
LastSeen: time.Now(),
|
||||
LastRotate: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
|
||||
return sessionID, true
|
||||
}
|
||||
|
||||
func (s *Store) ValidateAndRotate(token, clientIP string) (newToken string, valid bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session, exists := s.sessions[token]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if session.IP != clientIP {
|
||||
delete(s.sessions, session.CurrentToken)
|
||||
if session.PrevToken != "" {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
if time.Since(session.LastSeen) > sessionTTL {
|
||||
delete(s.sessions, session.CurrentToken)
|
||||
if session.PrevToken != "" {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
isPrevToken := token == session.PrevToken
|
||||
if isPrevToken {
|
||||
if !session.PrevRotatedAt.IsZero() && time.Since(session.PrevRotatedAt) > rotateGrace {
|
||||
delete(s.sessions, session.CurrentToken)
|
||||
delete(s.sessions, session.PrevToken)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
session.LastSeen = time.Now()
|
||||
|
||||
if time.Since(session.LastRotate) < rotateInterval {
|
||||
return "", true
|
||||
}
|
||||
|
||||
newTok := generateToken()
|
||||
|
||||
if session.PrevToken != "" {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
}
|
||||
|
||||
session.PrevToken = session.CurrentToken
|
||||
session.PrevRotatedAt = time.Now()
|
||||
session.CurrentToken = newTok
|
||||
session.LastRotate = time.Now()
|
||||
|
||||
s.sessions[newTok] = session
|
||||
|
||||
return newTok, true
|
||||
}
|
||||
|
||||
func (s *Store) DestroySession(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
session, exists := s.sessions[token]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
if session.PrevToken != "" {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
}
|
||||
delete(s.sessions, session.CurrentToken)
|
||||
}
|
||||
|
||||
func (s *Store) DestroyAllSessions() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions = make(map[string]*Session)
|
||||
}
|
||||
|
||||
func (s *Store) ChangeCredentials(newUsername, newPasswordHash string) {
|
||||
s.muConfig.Lock()
|
||||
defer s.muConfig.Unlock()
|
||||
if newUsername != "" {
|
||||
s.username = newUsername
|
||||
}
|
||||
if newPasswordHash != "" {
|
||||
s.password = newPasswordHash
|
||||
}
|
||||
s.DestroyAllSessions()
|
||||
}
|
||||
|
||||
func (s *Store) VerifyPassword(passwordHash string) bool {
|
||||
s.muConfig.RLock()
|
||||
defer s.muConfig.RUnlock()
|
||||
return subtle.ConstantTimeCompare([]byte(s.password), []byte(passwordHash)) == 1
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUsername(newUsername string) {
|
||||
s.muConfig.Lock()
|
||||
defer s.muConfig.Unlock()
|
||||
if newUsername != "" {
|
||||
s.username = newUsername
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePassword(newPasswordHash string, clientIP string) (sessionID string) {
|
||||
s.muConfig.Lock()
|
||||
s.password = newPasswordHash
|
||||
s.muConfig.Unlock()
|
||||
|
||||
s.DestroyAllSessions()
|
||||
|
||||
return s.createSessionAfterPasswordChange(clientIP)
|
||||
}
|
||||
|
||||
func (s *Store) createSessionAfterPasswordChange(clientIP string) string {
|
||||
sessionID := generateToken()
|
||||
session := &Session{
|
||||
CurrentToken: sessionID,
|
||||
IP: clientIP,
|
||||
CreatedAt: time.Now(),
|
||||
LastSeen: time.Now(),
|
||||
LastRotate: time.Now(),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func (s *Store) SetAPIKey(key string) {
|
||||
s.muConfig.Lock()
|
||||
defer s.muConfig.Unlock()
|
||||
s.apiKey = key
|
||||
}
|
||||
|
||||
func (s *Store) ValidateAPIKey(key string) bool {
|
||||
s.muConfig.RLock()
|
||||
defer s.muConfig.RUnlock()
|
||||
if s.apiKey == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(key), []byte(s.apiKey)) == 1
|
||||
}
|
||||
|
||||
func (s *Store) Cleanup() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
seen := make(map[*Session]bool)
|
||||
for _, session := range s.sessions {
|
||||
if seen[session] {
|
||||
continue
|
||||
}
|
||||
seen[session] = true
|
||||
if now.Sub(session.LastSeen) > sessionTTL {
|
||||
delete(s.sessions, session.CurrentToken)
|
||||
if session.PrevToken != "" {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if session.PrevToken != "" && !session.PrevRotatedAt.IsZero() && now.Sub(session.PrevRotatedAt) > rotateGrace {
|
||||
delete(s.sessions, session.PrevToken)
|
||||
session.PrevToken = ""
|
||||
}
|
||||
}
|
||||
for k, v := range s.nonces {
|
||||
if now.Sub(v.CreatedAt) > nonceExpiry {
|
||||
delete(s.nonces, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.SplitN(xff, ",", 2)
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
ip := r.RemoteAddr
|
||||
if idx := strings.LastIndex(ip, ":"); idx != -1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func GetSessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := GetSessionToken(r)
|
||||
clientIP := GetClientIP(r)
|
||||
|
||||
if token != "" {
|
||||
if newToken, valid := Global.ValidateAndRotate(token, clientIP); valid {
|
||||
if newToken != "" {
|
||||
SetSessionCookie(w, newToken)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
apiKey := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if Global.ValidateAPIKey(apiKey) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isHTMLRequest(r) {
|
||||
http.Redirect(w, r, "/login.html", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"success":false,"error":"unauthorized"}`)
|
||||
})
|
||||
}
|
||||
|
||||
func PublicAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/login.html" || r.URL.Path == "/api/auth/challenge" || r.URL.Path == "/api/auth/login" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/auth/") || r.URL.Path == "/style.css" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := GetSessionToken(r)
|
||||
clientIP := GetClientIP(r)
|
||||
|
||||
if token != "" {
|
||||
if newToken, valid := Global.ValidateAndRotate(token, clientIP); valid {
|
||||
if newToken != "" {
|
||||
SetSessionCookie(w, newToken)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
apiKey := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if Global.ValidateAPIKey(apiKey) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isHTMLRequest(r) {
|
||||
http.Redirect(w, r, "/login.html", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"success":false,"error":"unauthorized"}`)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isHTMLRequest(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return strings.Contains(accept, "text/html") ||
|
||||
strings.HasSuffix(r.URL.Path, ".html") ||
|
||||
r.URL.Path == "/" ||
|
||||
(!strings.HasPrefix(r.URL.Path, "/api/") && !strings.Contains(accept, "application/json"))
|
||||
}
|
||||
|
||||
func StartCleanup(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
Global.Cleanup()
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user