diff --git a/internal/darkstorm_backend/README.md b/internal/darkstorm_backend/README.md index 4cfe9cb..4239662 100644 --- a/internal/darkstorm_backend/README.md +++ b/internal/darkstorm_backend/README.md @@ -6,7 +6,7 @@ This is a purposefully "simple" application backend made specifically for _my_ a ### API Key -The special appID "darkstormManagement" is used to manage all apps. +The special appID "darkstormManagement" is used to manage all apps. ```json { @@ -44,6 +44,8 @@ Users are stored per backend and not per app. password: "hashed password", salt: "password salt", email: "email", + fails: 0, // number of failed attemps in a row. + timeout: 0, // unix timestamp (seconds) when current timeout ends. passwordChange: 0, // unix timestamp (seconds) of last password change perm: { appID: "user", // Optional. Apps should have a default permission level if thier appID is not in perm. @@ -105,6 +107,10 @@ If an error status code is returned then the body will be as follows. * invalidKey * API Key is invalid or does not have the needed permission for the request. +* invalidBody + * Body of the request is malformed. +* internal + * Server-side issue. ### Log @@ -149,16 +155,12 @@ Return: If returned status is 401, the errorCode will be one of the following: -* usernameTaken - * Username is already taken +* taken + * Username or email is already taken * usernameDisallowed * Username is not allowed (due to offensive words/phrases) * password * Password is to short or too long. -* email - * Email is already linked to an account -* disallowed - * Username contains words/phases that are not allowed #### Login @@ -179,7 +181,7 @@ Return: { token: "JWT Token", error: "Error", - timeout: 0, // login attempt timeout (in seconds). If non-zero, token will be empty. + timeout: 0, // login attempt timeout remaining (in seconds). If non-zero, token will be empty. } ``` diff --git a/internal/darkstorm_backend/darkstorm.go b/internal/darkstorm_backend/darkstorm.go index 8ba768d..d93766a 100644 --- a/internal/darkstorm_backend/darkstorm.go +++ b/internal/darkstorm_backend/darkstorm.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "sync" "time" ) @@ -12,16 +13,18 @@ type Backend struct { userTable Table[User] keyTable Table[ApiKey] m *http.ServeMux + apps map[string]App jwtPriv ed25519.PrivateKey jwtPub ed25519.PublicKey - apps map[string]App + userMutex sync.RWMutex } func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) { b := &Backend{ - keyTable: keyTable, - m: &http.ServeMux{}, - apps: make(map[string]App), + keyTable: keyTable, + m: &http.ServeMux{}, + apps: make(map[string]App), + userMutex: sync.RWMutex{}, } var hasLog, hasCrash bool for i := range apps { diff --git a/internal/darkstorm_backend/header.go b/internal/darkstorm_backend/header.go index 924304e..60d4b8a 100644 --- a/internal/darkstorm_backend/header.go +++ b/internal/darkstorm_backend/header.go @@ -19,6 +19,9 @@ type ParsedHeader struct { k *ApiKey } +// Parses the X-API-Key and Authorization headers. If the API Key provided but invalid (either due to expiring or isn't found), +// ErrApiKeyUnauthorized is part of the returned error (check with errors.Is). +// If the Authorization header is present but invalid, ErrTokenUnauthorized is part of the returned error (check with errors.Is). func (b *Backend) ParseHeader(r *http.Request) (ParsedHeader, error) { out := ParsedHeader{} key := r.Header.Get("X-API-Key") diff --git a/internal/darkstorm_backend/user.go b/internal/darkstorm_backend/user.go index c226f5e..5b5699a 100644 --- a/internal/darkstorm_backend/user.go +++ b/internal/darkstorm_backend/user.go @@ -3,11 +3,13 @@ package darkstorm import ( "crypto/rand" "encoding/base64" + "encoding/json" "errors" "net/http" "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/argon2" ) @@ -28,9 +30,34 @@ type User struct { Password string `json:"password" bson:"password"` Salt string `json:"salt" bson:"salt"` Email string `json:"email" bson:"email"` + Fails int `json:"fails" bson:"fails"` + Timeout int64 `json:"timeout" bson:"timeout"` PasswordChange int64 `json:"passwordChange" bson:"passwordChange"` } +func NewUser(username, password, email string) (User, error) { + id, err := uuid.NewV7() + if err != nil { + return User{}, err + } + salt, err := generateSalt() + if err != nil { + return User{}, err + } + u := User{ + Perm: make(map[string]string), + ID: id.String(), + Username: username, + Salt: salt, + Email: email, + } + u.Password, err = u.HashPassword(password) + if err != nil { + return u, err + } + return u, nil +} + type ReqUser struct { Perm map[string]string ID string @@ -82,12 +109,65 @@ type createUserRequest struct { } type createUserReturn struct { - Username string - Token string + Username string `json:"username"` + Token string `json:"token"` } func (b *Backend) CreateUser(w http.ResponseWriter, r *http.Request) { - //TODO + hdr, err := b.ParseHeader(r) + if hdr.k == nil || !hdr.k.Perm["user"] || errors.Is(err, ErrApiKeyUnauthorized) { + ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized") + return + } + defer r.Body.Close() + var req createUserRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil || req.Username == "" || req.Password == "" || req.Email == "" { + ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request") + return + } + if len(req.Password) < 12 || len(req.Password) > 128 { + ReturnError(w, http.StatusUnauthorized, "password", "Invalid password.") + return + } + // TODO: filter offensive words/phrases + b.userMutex.Lock() + defer b.userMutex.Unlock() + matchUsername, err := b.userTable.Find(map[string]any{"username": req.Username}) + if err != nil && !errors.Is(err, ErrNotFound) { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } else if (err == nil || errors.Is(err, ErrNotFound)) && len(matchUsername) > 0 { + ReturnError(w, http.StatusUnauthorized, "taken", "Username or email already used") + return + } + matchEmail, err := b.userTable.Find(map[string]any{"email": req.Email}) + if err != nil && !errors.Is(err, ErrNotFound) { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } else if (err == nil || errors.Is(err, ErrNotFound)) && len(matchEmail) > 0 { + ReturnError(w, http.StatusUnauthorized, "taken", "Username or email already used") + return + } + u, err := NewUser(req.Username, req.Password, req.Email) + if err != nil { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } + err = b.userTable.Insert(u) + if err != nil { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } + var ret createUserReturn + ret.Username = u.Username + ret.Token, err = b.generateJWT(u.toReqUser()) + if err != nil { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(ret) } type loginRequest struct { @@ -96,8 +176,9 @@ type loginRequest struct { } type loginReturn struct { - Token string - Timeout int + Token string `json:"token"` + Error string `json:"error"` + Timeout int64 `json:"timeout"` } func (b *Backend) Login(w http.ResponseWriter, r *http.Request) { @@ -106,5 +187,54 @@ func (b *Backend) Login(w http.ResponseWriter, r *http.Request) { ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized") return } - + defer r.Body.Close() + var req loginRequest + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil || req.Username == "" || req.Password == "" { + ReturnError(w, http.StatusBadRequest, "invalidBody", "Bad request") + return + } + b.userMutex.RLock() + defer b.userMutex.RUnlock() + var ret loginReturn + users, err := b.userTable.Find(map[string]any{"username": req.Username}) + if errors.Is(err, ErrNotFound) || len(users) != 1 { + ret.Error = "invalid" + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ret) + return + } + u := users[0] + if time.Unix(u.Timeout, 0).After(time.Now()) { + ret.Error = "timeout" + ret.Timeout = time.Now().Unix() - u.Timeout + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ret) + return + } + hash, err := u.HashPassword(req.Password) + if err != nil { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } + if u.Password == hash { + ret.Token, err = b.generateJWT(u.toReqUser()) + if err != nil { + ReturnError(w, http.StatusInternalServerError, "internal", "Server error") + return + } + json.NewEncoder(w).Encode(ret) + } else { + ret.Error = "invalid" + upd := map[string]any{"fails": u.Fails + 1} + if (u.Fails+1)%3 == 0 { + minutes := 3 ^ ((u.Fails / 3) - 1) + timeout := time.Now().Add(time.Duration(minutes) * time.Minute).Unix() + upd["timeout"] = timeout + ret.Timeout = timeout - time.Now().Unix() + } + b.userTable.PartUpdate(u.ID, upd) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ret) + } }