User login and creation
This commit is contained in:
@@ -44,6 +44,8 @@ Users are stored per backend and not per app.
|
|||||||
password: "hashed password",
|
password: "hashed password",
|
||||||
salt: "password salt",
|
salt: "password salt",
|
||||||
email: "email",
|
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
|
passwordChange: 0, // unix timestamp (seconds) of last password change
|
||||||
perm: {
|
perm: {
|
||||||
appID: "user", // Optional. Apps should have a default permission level if thier appID is not in 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
|
* invalidKey
|
||||||
* API Key is invalid or does not have the needed permission for the request.
|
* 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
|
### Log
|
||||||
|
|
||||||
@@ -149,16 +155,12 @@ Return:
|
|||||||
|
|
||||||
If returned status is 401, the errorCode will be one of the following:
|
If returned status is 401, the errorCode will be one of the following:
|
||||||
|
|
||||||
* usernameTaken
|
* taken
|
||||||
* Username is already taken
|
* Username or email is already taken
|
||||||
* usernameDisallowed
|
* usernameDisallowed
|
||||||
* Username is not allowed (due to offensive words/phrases)
|
* Username is not allowed (due to offensive words/phrases)
|
||||||
* password
|
* password
|
||||||
* Password is to short or too long.
|
* 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
|
#### Login
|
||||||
|
|
||||||
@@ -179,7 +181,7 @@ Return:
|
|||||||
{
|
{
|
||||||
token: "JWT Token",
|
token: "JWT Token",
|
||||||
error: "Error",
|
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.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,9 +13,10 @@ type Backend struct {
|
|||||||
userTable Table[User]
|
userTable Table[User]
|
||||||
keyTable Table[ApiKey]
|
keyTable Table[ApiKey]
|
||||||
m *http.ServeMux
|
m *http.ServeMux
|
||||||
|
apps map[string]App
|
||||||
jwtPriv ed25519.PrivateKey
|
jwtPriv ed25519.PrivateKey
|
||||||
jwtPub ed25519.PublicKey
|
jwtPub ed25519.PublicKey
|
||||||
apps map[string]App
|
userMutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) {
|
func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) {
|
||||||
@@ -22,6 +24,7 @@ func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) {
|
|||||||
keyTable: keyTable,
|
keyTable: keyTable,
|
||||||
m: &http.ServeMux{},
|
m: &http.ServeMux{},
|
||||||
apps: make(map[string]App),
|
apps: make(map[string]App),
|
||||||
|
userMutex: sync.RWMutex{},
|
||||||
}
|
}
|
||||||
var hasLog, hasCrash bool
|
var hasLog, hasCrash bool
|
||||||
for i := range apps {
|
for i := range apps {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type ParsedHeader struct {
|
|||||||
k *ApiKey
|
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) {
|
func (b *Backend) ParseHeader(r *http.Request) (ParsedHeader, error) {
|
||||||
out := ParsedHeader{}
|
out := ParsedHeader{}
|
||||||
key := r.Header.Get("X-API-Key")
|
key := r.Header.Get("X-API-Key")
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package darkstorm
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,9 +30,34 @@ type User struct {
|
|||||||
Password string `json:"password" bson:"password"`
|
Password string `json:"password" bson:"password"`
|
||||||
Salt string `json:"salt" bson:"salt"`
|
Salt string `json:"salt" bson:"salt"`
|
||||||
Email string `json:"email" bson:"email"`
|
Email string `json:"email" bson:"email"`
|
||||||
|
Fails int `json:"fails" bson:"fails"`
|
||||||
|
Timeout int64 `json:"timeout" bson:"timeout"`
|
||||||
PasswordChange int64 `json:"passwordChange" bson:"passwordChange"`
|
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 {
|
type ReqUser struct {
|
||||||
Perm map[string]string
|
Perm map[string]string
|
||||||
ID string
|
ID string
|
||||||
@@ -82,12 +109,65 @@ type createUserRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createUserReturn struct {
|
type createUserReturn struct {
|
||||||
Username string
|
Username string `json:"username"`
|
||||||
Token string
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) CreateUser(w http.ResponseWriter, r *http.Request) {
|
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 {
|
type loginRequest struct {
|
||||||
@@ -96,8 +176,9 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type loginReturn struct {
|
type loginReturn struct {
|
||||||
Token string
|
Token string `json:"token"`
|
||||||
Timeout int
|
Error string `json:"error"`
|
||||||
|
Timeout int64 `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) Login(w http.ResponseWriter, r *http.Request) {
|
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")
|
ReturnError(w, http.StatusUnauthorized, "invalidKey", "Application not authorized")
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user