feat: added activity log
This commit is contained in:
@@ -26,18 +26,21 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var user models.User
|
||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", "", "auth.register.failed", "auth", "", "Invalid request body", http.StatusBadRequest)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if user.Username == "" || user.Password == "" {
|
||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty")
|
||||
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Username and password required", http.StatusBadRequest)
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(user.Password) > 72 {
|
||||
log.Println("POST [api/register] User password too long")
|
||||
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Password exceeds maximum length", http.StatusUnprocessableEntity)
|
||||
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
@@ -54,10 +57,12 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := storage.AddUser(&user); err != nil {
|
||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "User already exists or could not be created", http.StatusBadRequest)
|
||||
http.Error(w, "user already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
RecordActivity(r, user.ID, strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.succeeded", "auth", "", "User registered", http.StatusCreated)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
||||
}
|
||||
@@ -69,6 +74,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", "", "auth.login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -76,12 +82,14 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := storage.GetUserByUsername(creds.Username)
|
||||
if err != nil {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(creds.Username)), "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.CheckPasswordHash(creds.Password, user.Password) {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials")
|
||||
RecordActivity(r, user.ID, user.Username, "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -105,11 +113,13 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
"requires_2fa": true,
|
||||
"two_factor_token": twoFactorToken,
|
||||
})
|
||||
RecordActivity(r, user.ID, user.Username, "auth.login.password_accepted_2fa_required", "auth", "", "Password accepted; 2FA required", http.StatusOK)
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
||||
return
|
||||
}
|
||||
|
||||
issueLoginSession(w, r, user)
|
||||
RecordActivity(r, user.ID, user.Username, "auth.login.succeeded", "auth", "", "Password login succeeded", http.StatusOK)
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
||||
}
|
||||
|
||||
@@ -120,6 +130,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -128,6 +139,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, auth.PurposeTwoFactorLogin, secret)
|
||||
if err != nil {
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -155,15 +167,18 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if !validTOTP && !usedRecoveryCode {
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
|
||||
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.failed", "auth", "", "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
issueLoginSession(w, r, user)
|
||||
if usedRecoveryCode {
|
||||
RecordActivity(r, user.ID, user.Username, "auth.recovery_code_login.succeeded", "auth", "", "Recovery-code login succeeded", http.StatusOK)
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
|
||||
return
|
||||
}
|
||||
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.succeeded", "auth", "", "2FA login succeeded", http.StatusOK)
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
|
||||
}
|
||||
|
||||
@@ -584,6 +599,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
|
||||
RecordActivity(r, "", "", "auth.refresh.failed", "auth", "", "Missing refresh token", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -593,6 +609,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
tokenRow, err := storage.GetRefreshToken(hashed)
|
||||
if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() {
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token")
|
||||
RecordActivity(r, tokenRow.UserID, "", "auth.refresh.failed", "auth", "", "Invalid refresh token", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -609,6 +626,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
issueLoginSession(w, r, user)
|
||||
RecordActivity(r, user.ID, user.Username, "auth.refresh.succeeded", "auth", "", "Refresh token rotated", http.StatusOK)
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
||||
}
|
||||
|
||||
|
||||
223
handlers/activity.go
Normal file
223
handlers/activity.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"MiauInv/auth"
|
||||
"MiauInv/models"
|
||||
"MiauInv/storage"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type activityResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *activityResponseWriter) WriteHeader(statusCode int) {
|
||||
w.statusCode = statusCode
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *activityResponseWriter) Write(data []byte) (int, error) {
|
||||
if w.statusCode == 0 {
|
||||
w.statusCode = http.StatusOK
|
||||
}
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func ActivityMiddleware(entityType string, includeGET bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recorder := &activityResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
statusCode := recorder.statusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
if !shouldRecordActivity(r, includeGET) {
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||
if !ok || claims == nil {
|
||||
return
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user, err := storage.GetUserById(claims.UserID); err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
RecordActivity(r, claims.UserID, username, activityAction(r.Method, r.URL.Path), entityType, activityEntityID(r), activityDetails(statusCode), statusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ActivityLog(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
limit := parseBoundedInt(r.URL.Query().Get("limit"), 50, 1, 100)
|
||||
offset := parseBoundedInt(r.URL.Query().Get("offset"), 0, 0, 100000)
|
||||
includeAll := claims.Role == models.RoleAdmin && strings.EqualFold(r.URL.Query().Get("all"), "true")
|
||||
|
||||
entries, err := storage.ListActivityLogs(claims.UserID, includeAll, limit, offset)
|
||||
if err != nil {
|
||||
log.Println("GET [api/activity] " + r.RemoteAddr + ": " + err.Error())
|
||||
http.Error(w, "Could not load activity log", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"activity": entries,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"all": includeAll,
|
||||
})
|
||||
}
|
||||
|
||||
func RecordActivity(r *http.Request, userID, username, action, entityType, entityID, details string, statusCode int) {
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
entry := models.ActivityLogEntry{
|
||||
UserID: userID,
|
||||
Username: truncateForActivity(username, 120),
|
||||
Action: truncateForActivity(action, 120),
|
||||
EntityType: truncateForActivity(entityType, 80),
|
||||
EntityID: truncateForActivity(entityID, 120),
|
||||
Details: truncateForActivity(details, 500),
|
||||
Method: r.Method,
|
||||
Path: truncateForActivity(r.URL.Path, 255),
|
||||
StatusCode: statusCode,
|
||||
Success: statusCode >= 200 && statusCode < 400,
|
||||
IPAddress: truncateForActivity(clientIP(r), 80),
|
||||
UserAgent: truncateForActivity(r.UserAgent(), 500),
|
||||
}
|
||||
if err := storage.AddActivityLog(entry); err != nil {
|
||||
log.Println("ACTIVITY " + r.RemoteAddr + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRecordActivity(r *http.Request, includeGET bool) bool {
|
||||
if r.Method == http.MethodOptions || r.Method == http.MethodHead {
|
||||
return false
|
||||
}
|
||||
if includeGET {
|
||||
return true
|
||||
}
|
||||
return r.Method != http.MethodGet
|
||||
}
|
||||
|
||||
func activityAction(method, path string) string {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "/api/logout":
|
||||
return "auth.logout"
|
||||
case path == "/api/account/username":
|
||||
return "account.username.update"
|
||||
case path == "/api/account/password":
|
||||
return "account.password.update"
|
||||
case path == "/api/2fa/setup":
|
||||
return "security.2fa.setup"
|
||||
case path == "/api/2fa/enable":
|
||||
return "security.2fa.enable"
|
||||
case path == "/api/2fa/disable":
|
||||
return "security.2fa.disable"
|
||||
case path == "/api/2fa/recovery-codes/regenerate":
|
||||
return "security.2fa.recovery_codes.regenerate"
|
||||
case path == "/api/passkeys/register/options":
|
||||
return "security.passkey.registration.start"
|
||||
case path == "/api/passkeys/register/finish":
|
||||
return "security.passkey.registration.finish"
|
||||
case path == "/api/passkeys/disable":
|
||||
return "security.passkey.disable"
|
||||
case path == "/api/passkeys":
|
||||
if method == http.MethodDelete {
|
||||
return "security.passkey.delete"
|
||||
}
|
||||
return "security.passkey.read"
|
||||
}
|
||||
|
||||
switch method {
|
||||
case http.MethodPost:
|
||||
return "inventory.create"
|
||||
case http.MethodPut:
|
||||
return "inventory.update"
|
||||
case http.MethodDelete:
|
||||
return "inventory.delete"
|
||||
case http.MethodGet:
|
||||
return "inventory.read"
|
||||
default:
|
||||
return strings.ToLower(method)
|
||||
}
|
||||
}
|
||||
|
||||
func activityEntityID(r *http.Request) string {
|
||||
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func activityDetails(statusCode int) string {
|
||||
if statusCode >= 200 && statusCode < 400 {
|
||||
return "Request completed successfully."
|
||||
}
|
||||
return http.StatusText(statusCode)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
|
||||
parts := strings.Split(forwardedFor, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func parseBoundedInt(raw string, fallback, min, max int) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func truncateForActivity(value string, max int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if max <= 0 || len(runes) <= max {
|
||||
return value
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -277,6 +277,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, "", "", "auth.passkey_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -327,6 +328,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err != nil {
|
||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.failed", "auth", "", "Could not verify passkey login", http.StatusUnauthorized)
|
||||
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -338,6 +340,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
issueLoginSession(w, r, user)
|
||||
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.succeeded", "auth", "", "Passkey login succeeded", http.StatusOK)
|
||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user