From e4f8b31e2927c54f7e252e2c5dc9d48ac13d15ac Mon Sep 17 00:00:00 2001 From: Caleb Gardner Date: Tue, 21 May 2024 05:50:07 -0500 Subject: [PATCH] (Probably) finished with docs and interfaces. Starting to build out the actual logic. --- internal/darkstorm_backend/README.md | 53 +++++++++++- internal/darkstorm_backend/app.go | 10 +++ internal/darkstorm_backend/crash.go | 23 +++++- internal/darkstorm_backend/darkstorm.go | 102 ++++++++++++------------ internal/darkstorm_backend/db.go | 10 ++- internal/darkstorm_backend/header.go | 65 +++++++++++++++ internal/darkstorm_backend/key.go | 6 +- internal/darkstorm_backend/log.go | 6 ++ internal/darkstorm_backend/user.go | 56 ++++++------- 9 files changed, 235 insertions(+), 96 deletions(-) create mode 100644 internal/darkstorm_backend/header.go diff --git a/internal/darkstorm_backend/README.md b/internal/darkstorm_backend/README.md index 5763724..4cfe9cb 100644 --- a/internal/darkstorm_backend/README.md +++ b/internal/darkstorm_backend/README.md @@ -6,11 +6,13 @@ This is a purposefully "simple" application backend made specifically for _my_ a ### API Key +The special appID "darkstormManagement" is used to manage all apps. + ```json { id: "API Key", appID: "appID", - death: -1, // unix timestamp when the key is no longer valid. -1 means there is not expected expiration (that can change in the future) + death: -1, // unix timestamp (seconds) when the key is no longer valid. -1 means there is not expected expiration (that can change in the future) perm: { user: true, // create and login users log: true, // log users @@ -37,12 +39,12 @@ Users are stored per backend and not per app. ```json { - id: "UUID", + id: "uuid", username: "username", password: "hashed password", salt: "password salt", email: "email", - passwordChange: 0, // unix timestamp of last password change + 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. } @@ -99,13 +101,18 @@ If an error status code is returned then the body will be as follows. } ``` +`errorCode`'s returned from the main library: + +* invalidKey + * API Key is invalid or does not have the needed permission for the request. + ### Log API Key must have the `log` permission. Request: -> POST: /log +> POST: /log/{uuid} ### Users @@ -171,10 +178,20 @@ Return: ```json { token: "JWT Token", + error: "Error", timeout: 0, // login attempt timeout (in seconds). If non-zero, token will be empty. } ``` +`token` and `error` are mutually exclusive. + +Possible `error` values: + +* timeout + * Account is currently timed-out. The `timeout` value will be non-zero. +* invalid + * Either the username or password is incorrect + ### Crash Report > TODO: Archive a crash to prevent it being reported again. @@ -187,6 +204,8 @@ Request: > POST: /crash +Request Body: + ```json { id: "UUID", // This is an ignored value, but it is highly recommended to include it to prevent reporting the same crash multiple times. @@ -203,3 +222,29 @@ API Key must have the `management` permission. Request: > DELETE: /crash/{crashID} + +With "darkstormManagement" key: + +> DELETE: /{appID}/crash/{crashID} + +#### Archive + +Archive an error, preventing error with these values to be ignored in the future. API Key must have the `management` permission. + +Request: + +> POST: /crash/archive + +With "darkstormManagement" key: + +> POST: /{appID}/crash/{crashID} + +Request Body: + +```json +{ + error: "error", + stack: "full stacktrace", + platform: "all", // Limit the archive to a specific platform, or use "all". +} +``` diff --git a/internal/darkstorm_backend/app.go b/internal/darkstorm_backend/app.go index 96d9108..dd33d16 100644 --- a/internal/darkstorm_backend/app.go +++ b/internal/darkstorm_backend/app.go @@ -1,6 +1,16 @@ package darkstorm +import "net/http" + +// An application interface. Both LogTable and CrashTable are optional, if they return nil then requests will be forbidden. type App interface { + AppID() string LogTable() Table[Log] CrashTable() CrashTable } + +type ExtendedApp interface { + // Extension is called for any calls to /{appID}/ + // Alternatively, use Backend.HandleFunc for more customizability + Extension(http.ResponseWriter, *http.Request) +} diff --git a/internal/darkstorm_backend/crash.go b/internal/darkstorm_backend/crash.go index 56cb458..5a1dade 100644 --- a/internal/darkstorm_backend/crash.go +++ b/internal/darkstorm_backend/crash.go @@ -2,6 +2,12 @@ package darkstorm import "net/http" +type ArchivedCrash struct { + Error string + Stack string + Platform string +} + type IndividualCrash struct { Platform string Error string @@ -27,7 +33,7 @@ type crashReq struct { Stack string } -func (b *Backend) ReportCrash(w http.ResponseWriter, r *http.Request) { +func (b *Backend) reportCrash(w http.ResponseWriter, r *http.Request) { hdr, err := b.ParseHeader(r) if hdr.k == nil || hdr.k.Perm["crash"] { w.WriteHeader(http.StatusUnauthorized) @@ -40,7 +46,20 @@ func (b *Backend) ReportCrash(w http.ResponseWriter, r *http.Request) { //TODO } -func (b *Backend) DeleteCrash(w http.ResponseWriter, r *http.Request) { +func (b *Backend) deleteCrash(w http.ResponseWriter, r *http.Request) { + hdr, err := b.ParseHeader(r) + if hdr.k == nil || hdr.k.Perm["management"] { + w.WriteHeader(http.StatusUnauthorized) + return + } + if err != nil { + //TODO + return + } + //TODO +} + +func (b *Backend) archiveCrash(w http.ResponseWriter, r *http.Request) { hdr, err := b.ParseHeader(r) if hdr.k == nil || hdr.k.Perm["management"] { w.WriteHeader(http.StatusUnauthorized) diff --git a/internal/darkstorm_backend/darkstorm.go b/internal/darkstorm_backend/darkstorm.go index efa85bc..8ba768d 100644 --- a/internal/darkstorm_backend/darkstorm.go +++ b/internal/darkstorm_backend/darkstorm.go @@ -2,39 +2,66 @@ package darkstorm import ( "crypto/ed25519" + "encoding/json" "errors" "net/http" - "strings" "time" - - "github.com/golang-jwt/jwt/v5" -) - -var ( - ErrApiKeyUnauthorized = errors.New("api key invalid") - ErrTokenUnauthorized = errors.New("token invalid") ) type Backend struct { userTable Table[User] - keyTable Table[Key] + keyTable Table[ApiKey] m *http.ServeMux jwtPriv ed25519.PrivateKey jwtPub ed25519.PublicKey - apps []App + apps map[string]App } -func NewBackend(keyTable Table[Key], apps ...App) (*Backend, error) { +func NewBackend(keyTable Table[ApiKey], apps ...App) (*Backend, error) { b := &Backend{ keyTable: keyTable, m: &http.ServeMux{}, - apps: apps, + apps: make(map[string]App), + } + var hasLog, hasCrash bool + for i := range apps { + _, has := b.apps[apps[i].AppID()] + if has { + return nil, errors.New("duplicate AppIDs found") + } + b.apps[apps[i].AppID()] = apps[i] + if ext, is := apps[i].(ExtendedApp); is { + b.m.HandleFunc("/"+apps[i].AppID()+"/", ext.Extension) + } + if !hasLog && apps[i].LogTable() != nil { + hasLog = true + } + if !hasCrash && apps[i].CrashTable() != nil { + hasCrash = true + } + } + if hasLog { + b.m.HandleFunc("POST /log/{uuid}", b.log) + } + if hasCrash { + b.m.HandleFunc("POST /crash", b.reportCrash) + b.m.HandleFunc("DELETE /crash/{crashID}", b.deleteCrash) + b.m.HandleFunc("POST /crash/archive", b.archiveCrash) + b.m.HandleFunc("DELETE /{appID}/crash/{crashID}", b.deleteCrash) + b.m.HandleFunc("POST /{appID}/crash/archive", b.archiveCrash) } - //TODO: register paths to the mux b.startCleanupLoop() return b, nil } +func (b *Backend) startCleanupLoop() { + go func() { + for range time.Tick(24 * time.Hour) { + //TODO + } + }() +} + func (b *Backend) AddUserAuth(userTable Table[User], privKey, pubKey []byte) { b.userTable = userTable b.jwtPriv = privKey @@ -45,46 +72,19 @@ func (b *Backend) HandleFunc(pattern string, h http.HandlerFunc) { b.m.HandleFunc(pattern, h) } -func (b *Backend) startCleanupLoop() { - go func() { - for range time.Tick(6 * time.Hour) { - //TODO - } - }() +func (b *Backend) GetApp(a *ApiKey) App { + return b.apps[a.AppID] } -type ParsedHeader struct { - u *ReqUser - k *Key +type retError struct { + ErrorCode string `json:"errorCode"` + ErrorMsg string `json:"errorMsg"` } -func (b *Backend) ParseHeader(r *http.Request) (ParsedHeader, error) { - out := ParsedHeader{} - key := r.Header.Get("X-API-Key") - token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if key != "" { - apiKey, err := b.keyTable.Get(key) - if err != nil { - return out, errors.Join(ErrApiKeyUnauthorized, err) - } - out.k = &apiKey - } - if token != "" && b.userTable != nil { - t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { - return b.jwtPub, nil - }, jwt.WithIssuer("darkstorm.tech"), jwt.WithExpirationRequired()) - if err != nil { - return out, errors.Join(ErrTokenUnauthorized, err) - } - sub, err := t.Claims.GetSubject() - if err != nil { - return out, errors.Join(ErrTokenUnauthorized, err) - } - usr, err := b.userTable.Get(sub) - if err != nil{ - return out, errors.Join(ErrTokenUnauthorized, err) - } - - } - return out, nil +func ReturnError(w http.ResponseWriter, status int, code, msg string) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(retError{ + ErrorCode: code, + ErrorMsg: msg, + }) } diff --git a/internal/darkstorm_backend/db.go b/internal/darkstorm_backend/db.go index 87a16be..0a9e9ac 100644 --- a/internal/darkstorm_backend/db.go +++ b/internal/darkstorm_backend/db.go @@ -3,7 +3,7 @@ package darkstorm import "errors" var ( - ErrIDNotFound = errors.New("id not found in table") + ErrNotFound = errors.New("no matches found in table") ) type IDStruct interface { @@ -12,12 +12,14 @@ type IDStruct interface { type Table[T IDStruct] interface { Get(ID string) (data T, err error) + Find(values map[string]any) ([]T, error) Insert(data T) error - Update(data T) error - Remove(ID string) + Remove(ID string) error + FullUpdate(ID string, data T) error + PartUpdate(ID string, update map[string]any) error } type CrashTable interface { Table[CrashReport] - InsertCrash(report IndividualCrash) error + InsertCrash(ID string, report IndividualCrash) error } diff --git a/internal/darkstorm_backend/header.go b/internal/darkstorm_backend/header.go new file mode 100644 index 0000000..924304e --- /dev/null +++ b/internal/darkstorm_backend/header.go @@ -0,0 +1,65 @@ +package darkstorm + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrApiKeyUnauthorized = errors.New("api key present but invalid") + ErrTokenUnauthorized = errors.New("token present but invalid") +) + +type ParsedHeader struct { + u *ReqUser + k *ApiKey +} + +func (b *Backend) ParseHeader(r *http.Request) (ParsedHeader, error) { + out := ParsedHeader{} + key := r.Header.Get("X-API-Key") + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if key != "" { + apiKey, err := b.keyTable.Get(key) + if err != nil { + return out, errors.Join(ErrApiKeyUnauthorized, err) + } + if apiKey.Death > 0 && time.Unix(apiKey.Death, 0).Before(time.Now()) { + return out, ErrApiKeyUnauthorized + } + out.k = &apiKey + } + if token != "" && b.userTable != nil { + t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + return b.jwtPub, nil + }, jwt.WithIssuer("darkstorm.tech"), jwt.WithExpirationRequired()) + if err != nil { + return out, errors.Join(ErrTokenUnauthorized, err) + } + exp, _ := t.Claims.GetExpirationTime() + if exp.Time.Before(time.Now()) { + return out, ErrTokenUnauthorized + } + sub, err := t.Claims.GetSubject() + if err != nil { + return out, errors.Join(ErrTokenUnauthorized, err) + } + usr, err := b.userTable.Get(sub) + if err != nil { + return out, errors.Join(ErrTokenUnauthorized, err) + } + iss, err := t.Claims.GetIssuedAt() + if err != nil { + return out, errors.Join(ErrTokenUnauthorized, err) + } + if usr.PasswordChange > 0 && iss.Time.Before(time.Unix(usr.PasswordChange, 0)) { + return out, ErrTokenUnauthorized + } + out.u = usr.toReqUser() + } + return out, nil +} diff --git a/internal/darkstorm_backend/key.go b/internal/darkstorm_backend/key.go index 74dd5e9..0fca0fd 100644 --- a/internal/darkstorm_backend/key.go +++ b/internal/darkstorm_backend/key.go @@ -1,12 +1,12 @@ package darkstorm -type Key struct { +type ApiKey struct { Perm map[string]bool ID string AppID string - Death int + Death int64 } -func (k Key) GetID() string { +func (k ApiKey) GetID() string { return k.ID } diff --git a/internal/darkstorm_backend/log.go b/internal/darkstorm_backend/log.go index 4976e51..309fb48 100644 --- a/internal/darkstorm_backend/log.go +++ b/internal/darkstorm_backend/log.go @@ -1,5 +1,7 @@ package darkstorm +import "net/http" + type Log struct { ID string Platform string @@ -9,3 +11,7 @@ type Log struct { func (l Log) GetID() string { return l.ID } + +func (b *Backend) log(w http.ResponseWriter, r *http.Request) { + //TODO +} diff --git a/internal/darkstorm_backend/user.go b/internal/darkstorm_backend/user.go index a456306..c226f5e 100644 --- a/internal/darkstorm_backend/user.go +++ b/internal/darkstorm_backend/user.go @@ -5,8 +5,9 @@ import ( "encoding/base64" "errors" "net/http" + "time" - "github.com/google/uuid" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/argon2" ) @@ -21,13 +22,13 @@ func generateSalt() (string, error) { } type User struct { - Perm map[string]string - ID string - Username string - Password string - Salt string - Email string - PasswordChange uint64 + Perm map[string]string `json:"perm" bson:"perm"` + ID string `json:"id" bson:"_id"` + Username string `json:"username" bson:"username"` + Password string `json:"password" bson:"password"` + Salt string `json:"salt" bson:"salt"` + Email string `json:"email" bson:"email"` + PasswordChange int64 `json:"passwordChange" bson:"passwordChange"` } type ReqUser struct { @@ -36,35 +37,21 @@ type ReqUser struct { Username string } -func NewUser(username, password, email string) (*User, error) { - if len(password) < 12 || len(password) > 128 { - return nil, ErrPasswordLength - } - id, err := uuid.NewV7() - if err != nil { - return nil, err - } - salt, err := generateSalt() - if err != nil { - return nil, err - } - out := &User{ - Perm: make(map[string]string), - ID: id.String(), - Username: username, - Salt: salt, - Email: email, - } - out.Password, err = out.HashPassword(password) - return out, err +func (b *Backend) generateJWT(r *ReqUser) (string, error) { + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{ + ID: r.ID, + Issuer: "darkstorm.tech", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)), + }).SignedString(b.jwtPriv) } func (u User) GetID() string { return u.ID } -func (u User) toReqUser() ReqUser { - return ReqUser{ +func (u User) toReqUser() *ReqUser { + return &ReqUser{ Perm: u.Perm, ID: u.ID, Username: u.Username, @@ -114,5 +101,10 @@ type loginReturn struct { } func (b *Backend) Login(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 + } + }