(Probably) finished with docs and interfaces.

Starting to build out the actual logic.
This commit is contained in:
Caleb Gardner
2024-05-21 05:50:07 -05:00
parent 4244b6985a
commit e4f8b31e29
9 changed files with 235 additions and 96 deletions
+49 -4
View File
@@ -6,11 +6,13 @@ This is a purposefully "simple" application backend made specifically for _my_ a
### API Key ### API Key
The special appID "darkstormManagement" is used to manage all apps.
```json ```json
{ {
id: "API Key", id: "API Key",
appID: "appID", 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: { perm: {
user: true, // create and login users user: true, // create and login users
log: true, // log users log: true, // log users
@@ -37,12 +39,12 @@ Users are stored per backend and not per app.
```json ```json
{ {
id: "UUID", id: "uuid",
username: "username", username: "username",
password: "hashed password", password: "hashed password",
salt: "password salt", salt: "password salt",
email: "email", email: "email",
passwordChange: 0, // unix timestamp 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.
} }
@@ -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 ### Log
API Key must have the `log` permission. API Key must have the `log` permission.
Request: Request:
> POST: /log > POST: /log/{uuid}
### Users ### Users
@@ -171,10 +178,20 @@ Return:
```json ```json
{ {
token: "JWT Token", token: "JWT Token",
error: "Error",
timeout: 0, // login attempt timeout (in seconds). If non-zero, token will be empty. 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 ### Crash Report
> TODO: Archive a crash to prevent it being reported again. > TODO: Archive a crash to prevent it being reported again.
@@ -187,6 +204,8 @@ Request:
> POST: /crash > POST: /crash
Request Body:
```json ```json
{ {
id: "UUID", // This is an ignored value, but it is highly recommended to include it to prevent reporting the same crash multiple times. 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: Request:
> DELETE: /crash/{crashID} > 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".
}
```
+10
View File
@@ -1,6 +1,16 @@
package darkstorm 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 { type App interface {
AppID() string
LogTable() Table[Log] LogTable() Table[Log]
CrashTable() CrashTable 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)
}
+21 -2
View File
@@ -2,6 +2,12 @@ package darkstorm
import "net/http" import "net/http"
type ArchivedCrash struct {
Error string
Stack string
Platform string
}
type IndividualCrash struct { type IndividualCrash struct {
Platform string Platform string
Error string Error string
@@ -27,7 +33,7 @@ type crashReq struct {
Stack string 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) hdr, err := b.ParseHeader(r)
if hdr.k == nil || hdr.k.Perm["crash"] { if hdr.k == nil || hdr.k.Perm["crash"] {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@@ -40,7 +46,20 @@ func (b *Backend) ReportCrash(w http.ResponseWriter, r *http.Request) {
//TODO //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) hdr, err := b.ParseHeader(r)
if hdr.k == nil || hdr.k.Perm["management"] { if hdr.k == nil || hdr.k.Perm["management"] {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
+51 -51
View File
@@ -2,39 +2,66 @@ package darkstorm
import ( import (
"crypto/ed25519" "crypto/ed25519"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrApiKeyUnauthorized = errors.New("api key invalid")
ErrTokenUnauthorized = errors.New("token invalid")
) )
type Backend struct { type Backend struct {
userTable Table[User] userTable Table[User]
keyTable Table[Key] keyTable Table[ApiKey]
m *http.ServeMux m *http.ServeMux
jwtPriv ed25519.PrivateKey jwtPriv ed25519.PrivateKey
jwtPub ed25519.PublicKey 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{ b := &Backend{
keyTable: keyTable, keyTable: keyTable,
m: &http.ServeMux{}, 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() b.startCleanupLoop()
return b, nil 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) { func (b *Backend) AddUserAuth(userTable Table[User], privKey, pubKey []byte) {
b.userTable = userTable b.userTable = userTable
b.jwtPriv = privKey b.jwtPriv = privKey
@@ -45,46 +72,19 @@ func (b *Backend) HandleFunc(pattern string, h http.HandlerFunc) {
b.m.HandleFunc(pattern, h) b.m.HandleFunc(pattern, h)
} }
func (b *Backend) startCleanupLoop() { func (b *Backend) GetApp(a *ApiKey) App {
go func() { return b.apps[a.AppID]
for range time.Tick(6 * time.Hour) {
//TODO
}
}()
} }
type ParsedHeader struct { type retError struct {
u *ReqUser ErrorCode string `json:"errorCode"`
k *Key ErrorMsg string `json:"errorMsg"`
} }
func (b *Backend) ParseHeader(r *http.Request) (ParsedHeader, error) { func ReturnError(w http.ResponseWriter, status int, code, msg string) {
out := ParsedHeader{} w.WriteHeader(status)
key := r.Header.Get("X-API-Key") json.NewEncoder(w).Encode(retError{
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") ErrorCode: code,
if key != "" { ErrorMsg: msg,
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
} }
+6 -4
View File
@@ -3,7 +3,7 @@ package darkstorm
import "errors" import "errors"
var ( var (
ErrIDNotFound = errors.New("id not found in table") ErrNotFound = errors.New("no matches found in table")
) )
type IDStruct interface { type IDStruct interface {
@@ -12,12 +12,14 @@ type IDStruct interface {
type Table[T IDStruct] interface { type Table[T IDStruct] interface {
Get(ID string) (data T, err error) Get(ID string) (data T, err error)
Find(values map[string]any) ([]T, error)
Insert(data T) error Insert(data T) error
Update(data T) error Remove(ID string) error
Remove(ID string) FullUpdate(ID string, data T) error
PartUpdate(ID string, update map[string]any) error
} }
type CrashTable interface { type CrashTable interface {
Table[CrashReport] Table[CrashReport]
InsertCrash(report IndividualCrash) error InsertCrash(ID string, report IndividualCrash) error
} }
+65
View File
@@ -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
}
+3 -3
View File
@@ -1,12 +1,12 @@
package darkstorm package darkstorm
type Key struct { type ApiKey struct {
Perm map[string]bool Perm map[string]bool
ID string ID string
AppID string AppID string
Death int Death int64
} }
func (k Key) GetID() string { func (k ApiKey) GetID() string {
return k.ID return k.ID
} }
+6
View File
@@ -1,5 +1,7 @@
package darkstorm package darkstorm
import "net/http"
type Log struct { type Log struct {
ID string ID string
Platform string Platform string
@@ -9,3 +11,7 @@ type Log struct {
func (l Log) GetID() string { func (l Log) GetID() string {
return l.ID return l.ID
} }
func (b *Backend) log(w http.ResponseWriter, r *http.Request) {
//TODO
}
+24 -32
View File
@@ -5,8 +5,9 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"net/http" "net/http"
"time"
"github.com/google/uuid" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
@@ -21,13 +22,13 @@ func generateSalt() (string, error) {
} }
type User struct { type User struct {
Perm map[string]string Perm map[string]string `json:"perm" bson:"perm"`
ID string ID string `json:"id" bson:"_id"`
Username string Username string `json:"username" bson:"username"`
Password string Password string `json:"password" bson:"password"`
Salt string Salt string `json:"salt" bson:"salt"`
Email string Email string `json:"email" bson:"email"`
PasswordChange uint64 PasswordChange int64 `json:"passwordChange" bson:"passwordChange"`
} }
type ReqUser struct { type ReqUser struct {
@@ -36,35 +37,21 @@ type ReqUser struct {
Username string Username string
} }
func NewUser(username, password, email string) (*User, error) { func (b *Backend) generateJWT(r *ReqUser) (string, error) {
if len(password) < 12 || len(password) > 128 { return jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
return nil, ErrPasswordLength ID: r.ID,
} Issuer: "darkstorm.tech",
id, err := uuid.NewV7() IssuedAt: jwt.NewNumericDate(time.Now()),
if err != nil { ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)),
return nil, err }).SignedString(b.jwtPriv)
}
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 (u User) GetID() string { func (u User) GetID() string {
return u.ID return u.ID
} }
func (u User) toReqUser() ReqUser { func (u User) toReqUser() *ReqUser {
return ReqUser{ return &ReqUser{
Perm: u.Perm, Perm: u.Perm,
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
@@ -114,5 +101,10 @@ type loginReturn struct {
} }
func (b *Backend) Login(w http.ResponseWriter, r *http.Request) { 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
}
} }