diff --git a/cmd/rpdata-server/main.go b/cmd/rpdata-server/main.go index 40ddc36..c59ff0f 100644 --- a/cmd/rpdata-server/main.go +++ b/cmd/rpdata-server/main.go @@ -10,10 +10,8 @@ import ( "git.aiterp.net/rpdata/api/database" "git.aiterp.net/rpdata/api/graph2" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/internal/config" "git.aiterp.net/rpdata/api/internal/instrumentation" - "git.aiterp.net/rpdata/api/internal/store" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/services" "github.com/99designs/gqlgen/handler" @@ -21,11 +19,6 @@ import ( ) func main() { - err := store.Init() - if err != nil { - log.Fatalln("Failed to init store:", err) - } - db, err := database.Init(config.Global().Database) if err != nil { log.Fatalln("Failed to init db:", err) @@ -108,7 +101,7 @@ func queryHandler(services *services.Bundle) http.HandlerFunc { w.Header().Add("X-Emerald-Herald", "Lest this land swallow you whole... As it has so many others.") } - r = auth.RequestWithToken(r) + r = services.Auth.RequestWithToken(r) handler.ServeHTTP(w, r) } diff --git a/database/database.go b/database/database.go index 358b8da..a719fcb 100644 --- a/database/database.go +++ b/database/database.go @@ -22,6 +22,8 @@ type Database interface { Stories() repositories.StoryRepository Chapters() repositories.ChapterRepository Comments() repositories.CommentRepository + Keys() repositories.KeyRepository + Users() repositories.UserRepository Close(ctx context.Context) error } diff --git a/database/mongodb/db.go b/database/mongodb/db.go index b2d4e20..d8ecd38 100644 --- a/database/mongodb/db.go +++ b/database/mongodb/db.go @@ -24,6 +24,8 @@ type MongoDB struct { stories repositories.StoryRepository chapters repositories.ChapterRepository comments repositories.CommentRepository + keys repositories.KeyRepository + users repositories.UserRepository } func (m *MongoDB) Changes() repositories.ChangeRepository { @@ -66,6 +68,14 @@ func (m *MongoDB) Comments() repositories.CommentRepository { return m.comments } +func (m *MongoDB) Keys() repositories.KeyRepository { + return m.keys +} + +func (m *MongoDB) Users() repositories.UserRepository { + return m.users +} + func (m *MongoDB) Close(ctx context.Context) error { m.session.Close() return nil @@ -147,6 +157,18 @@ func Init(cfg config.Database) (*MongoDB, error) { return nil, err } + keys, err := newKeyRepository(db) + if err != nil { + session.Close() + return nil, err + } + + users, err := newUserRepository(db) + if err != nil { + session.Close() + return nil, err + } + go posts.fixPositions(logs) return &MongoDB{ @@ -162,6 +184,8 @@ func Init(cfg config.Database) (*MongoDB, error) { logs: logs, posts: posts, files: files, + keys: keys, + users: users, }, nil } diff --git a/database/mongodb/keys.go b/database/mongodb/keys.go new file mode 100644 index 0000000..c53733e --- /dev/null +++ b/database/mongodb/keys.go @@ -0,0 +1,69 @@ +package mongodb + +import ( + "context" + "git.aiterp.net/rpdata/api/internal/generate" + "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/repositories" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" +) + +type keyRepository struct { + keys *mgo.Collection +} + +func newKeyRepository(db *mgo.Database) (repositories.KeyRepository, error) { + collection := db.C("auth.keys") + + err := collection.EnsureIndexKey("user") + if err != nil { + return nil, err + } + + return &keyRepository{keys: collection}, nil +} + +func (r *keyRepository) Find(ctx context.Context, id string) (*models.Key, error) { + key := models.Key{} + err := r.keys.FindId(id).One(&key) + if err != nil { + return nil, err + } + + return &key, err +} + +func (r *keyRepository) List(ctx context.Context, filter models.KeyFilter) ([]*models.Key, error) { + query := bson.M{} + if filter.UserID != nil { + query["user"] = *filter.UserID + } + + keys := make([]*models.Key, 0, 4) + err := r.keys.Find(query).All(&keys) + if err != nil { + if err == mgo.ErrNotFound { + return nil, nil + } + + return nil, err + } + + return keys, nil +} + +func (r *keyRepository) Insert(ctx context.Context, key models.Key) (*models.Key, error) { + key.ID = generate.KeyID() + + err := r.keys.Insert(&key) + if err != nil { + return nil, err + } + + return &key, nil +} + +func (r *keyRepository) Delete(ctx context.Context, key models.Key) error { + return r.keys.RemoveId(key.ID) +} diff --git a/database/mongodb/users.go b/database/mongodb/users.go new file mode 100644 index 0000000..01e3891 --- /dev/null +++ b/database/mongodb/users.go @@ -0,0 +1,41 @@ +package mongodb + +import ( + "context" + "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/repositories" + "github.com/globalsign/mgo" +) + +type userRepository struct { + users *mgo.Collection +} + +func newUserRepository(db *mgo.Database) (repositories.UserRepository, error) { + collection := db.C("core.users") + + return &userRepository{users: collection}, nil +} + +func (r *userRepository) Find(ctx context.Context, id string) (*models.User, error) { + user := new(models.User) + err := r.users.FindId(id).One(user) + if err != nil { + if err == mgo.ErrNotFound { + return nil, repositories.ErrNotFound + } + + return nil, err + } + + return user, nil +} + +func (r *userRepository) Insert(ctx context.Context, user models.User) (*models.User, error) { + err := r.users.Insert(user) + if err != nil { + return nil, err + } + + return &user, nil +} diff --git a/graph2/complexity.go b/graph2/complexity.go index 163c7f2..18f5ec9 100644 --- a/graph2/complexity.go +++ b/graph2/complexity.go @@ -3,7 +3,6 @@ package graph2 import ( "git.aiterp.net/rpdata/api/graph2/graphcore" "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/files" ) func complexity() (cr graphcore.ComplexityRoot) { @@ -67,7 +66,7 @@ func complexity() (cr graphcore.ComplexityRoot) { cr.Query.File = func(childComplexity int, id string) int { return childComplexity + findComplexity } - cr.Query.Files = func(childComplexity int, filter *files.Filter) int { + cr.Query.Files = func(childComplexity int, filter *models.FileFilter) int { return childComplexity + listComplexity } cr.Query.Changes = func(childComplexity int, filter *models.ChangeFilter) int { diff --git a/graph2/gqlgen.yml b/graph2/gqlgen.yml index cdba4dc..b43ebe6 100644 --- a/graph2/gqlgen.yml +++ b/graph2/gqlgen.yml @@ -52,7 +52,7 @@ models: File: model: git.aiterp.net/rpdata/api/models.File FilesFilter: - model: git.aiterp.net/rpdata/api/models/files.Filter + model: git.aiterp.net/rpdata/api/models.FileFilter Change: model: git.aiterp.net/rpdata/api/models.Change ChangeModel: diff --git a/graph2/graph.go b/graph2/graph.go index b19786b..355f64d 100644 --- a/graph2/graph.go +++ b/graph2/graph.go @@ -55,5 +55,5 @@ func (r *rootResolver) Change() graphcore.ChangeResolver { } func (r *rootResolver) Token() graphcore.TokenResolver { - return &types.TokenResolver + return types.TokenResolver(r.s) } diff --git a/graph2/resolvers/file.go b/graph2/resolvers/file.go index 100b6f6..2e72649 100644 --- a/graph2/resolvers/file.go +++ b/graph2/resolvers/file.go @@ -2,48 +2,21 @@ package resolvers import ( "context" - - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/files" ) func (r *queryResolver) File(ctx context.Context, id string) (*models.File, error) { - file, err := files.FindID(id) - if err != nil { - return nil, err - } - - return &file, nil + return r.s.Files.Find(ctx, id) } -func (r *queryResolver) Files(ctx context.Context, filter *files.Filter) ([]*models.File, error) { - token := auth.TokenFromContext(ctx) - +func (r *queryResolver) Files(ctx context.Context, filter *models.FileFilter) ([]*models.File, error) { if filter == nil { - filter = &files.Filter{} - } + public := true - // Only allow users to view public files that are not their own. - if token != nil { - if filter.Public == nil || *filter.Public == false { - filter.Author = &token.UserID + filter = &models.FileFilter{ + Public: &public, } - } else { - filter.Public = &trueValue } - files, err := files.List(filter) - if err != nil { - return nil, err - } - - files2 := make([]*models.File, len(files)) - for i := range files { - files2[i] = &files[i] - } - - return files2, nil + return r.s.Files.List(ctx, *filter) } - -var trueValue = true diff --git a/graph2/resolvers/token.go b/graph2/resolvers/token.go index ed28edc..e091953 100644 --- a/graph2/resolvers/token.go +++ b/graph2/resolvers/token.go @@ -4,14 +4,13 @@ import ( "context" "errors" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" ) func (r *queryResolver) Token(ctx context.Context) (*models.Token, error) { - token := auth.TokenFromContext(ctx) + token := r.s.Auth.TokenFromContext(ctx) if !token.Authenticated() { - return nil, errors.New("No (valid) token") + return nil, errors.New("no (valid) token") } return token, nil diff --git a/graph2/schema/types/File.gql b/graph2/schema/types/File.gql index 9180a3d..ef38c97 100644 --- a/graph2/schema/types/File.gql +++ b/graph2/schema/types/File.gql @@ -35,7 +35,7 @@ input FilesFilter { public: Boolean # Limit the MIME types of the files. - mimeType: [String!] + mimeTypes: [String!] } # Input for editFile mutation diff --git a/graph2/types/token.go b/graph2/types/token.go index 3e2ef66..39f1cfa 100644 --- a/graph2/types/token.go +++ b/graph2/types/token.go @@ -2,21 +2,20 @@ package types import ( "context" + "git.aiterp.net/rpdata/api/services" "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/users" ) -type tokenResolver struct{} +type tokenResolver struct { + auth *services.AuthService +} func (r *tokenResolver) User(ctx context.Context, token *models.Token) (*models.User, error) { - user, err := users.Find(token.UserID) - if err != nil { - return nil, err - } - - return &user, nil + return r.auth.FindUser(ctx, token.UserID) } // TokenResolver is a resolver -var TokenResolver tokenResolver +func TokenResolver(s *services.Bundle) *tokenResolver { + return &tokenResolver{auth: s.Auth} +} diff --git a/internal/auth/key.go b/internal/auth/key.go deleted file mode 100644 index 592cfba..0000000 --- a/internal/auth/key.go +++ /dev/null @@ -1,109 +0,0 @@ -package auth - -import ( - "crypto/rand" - "encoding/binary" - "errors" - "strconv" - - "git.aiterp.net/rpdata/api/internal/store" - "github.com/globalsign/mgo" -) - -var keyCollection *mgo.Collection - -// A Key contains a JWT secret and the limitations of it. There are two types of -// keys, single-user keys and wildcard keys. The former is used to authenticate -// a single user (e.g. the logbot) through an API while the latter is only for -// services that can be trusted to perform its own authentication (a frontend). -type Key struct { - ID string `bson:"_id"` - Name string `bson:"name"` - User string `bson:"user"` - Secret string `bson:"secret"` -} - -// ValidForUser returns true if the key's user is the same as -// the user, or it's a wildcard key. -func (key *Key) ValidForUser(user string) bool { - return key.User == user || key.User == "*" -} - -// FindKey finds a key by kid (key ID) -func FindKey(kid string) (Key, error) { - key := Key{} - err := keyCollection.FindId(kid).One(&key) - - return key, err -} - -// NewKey generates a new key for the user and name. This -// does not allow generating wildcard keys, they have to be -// manually inserted into the DB. -func NewKey(name, user string) (*Key, error) { - if user == "*" { - return nil, errors.New("auth: wildcard keys not allowed") - } - - secret, err := makeKeySecret() - if err != nil { - return nil, err - } - - key := &Key{ - ID: makeKeyID(), - Name: name, - User: user, - Secret: secret, - } - - if err := keyCollection.Insert(key); err != nil { - return nil, err - } - - return key, nil -} - -func init() { - store.HandleInit(func(db *mgo.Database) { - keyCollection = db.C("auth.keys") - - keyCollection.EnsureIndexKey("user") - }) -} - -// makeKeyID makes a random story ID that's 16 characters long -func makeKeyID() string { - result := "K" - offset := 0 - data := make([]byte, 32) - - rand.Read(data) - for len(result) < 16 { - result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36) - offset += 8 - - if offset >= 32 { - rand.Read(data) - offset = 0 - } - } - - return result[:16] -} - -func makeKeySecret() (string, error) { - data := make([]byte, 64) - alphabet := "0123456789abcdefghjiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSUTVWXYZ-_" - - _, err := rand.Read(data) - if err != nil { - return "", err - } - - for i := range data { - data[i] = alphabet[data[i]%64] - } - - return string(data), nil -} diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go deleted file mode 100644 index 48d8445..0000000 --- a/internal/auth/permissions.go +++ /dev/null @@ -1,33 +0,0 @@ -package auth - -// AllPermissions gets all permissions and their purpose -func AllPermissions() map[string]string { - return map[string]string{ - "member": "Can add/edit/remove own content", - "user.edit": "Can edit non-owned users", - "character.add": "Can add non-owned characters", - "character.edit": "Can edit non-owned characters", - "character.remove": "Can remove non-owned characters", - "channel.add": "Can add channels", - "channel.edit": "Can edit channels", - "channel.remove": "Can remove channels", - "comment.edit": "Can edit non-owned comments", - "comment.remove": "Can remove non-owned comments", - "log.add": "Can add logs", - "log.edit": "Can edit logs", - "log.remove": "Can remove logs", - "post.add": "Can add posts", - "post.edit": "Can edit posts", - "post.move": "Can move posts", - "post.remove": "Can remove posts", - "story.add": "Can add non-owned stories", - "story.edit": "Can edit non-owned stories", - "story.remove": "Can remove non-owned stories", - "story.unlisted": "Can view non-owned unlisted stories", - "file.upload": "Can upload files", - "file.list": "Can list non-owned files", - "file.view": "Can view non-owned files", - "file.edit": "Can edit non-owned files", - "file.remove": "Can remove non-owned files", - } -} diff --git a/internal/auth/permitted.go b/internal/auth/permitted.go deleted file mode 100644 index 5cbd2f7..0000000 --- a/internal/auth/permitted.go +++ /dev/null @@ -1,60 +0,0 @@ -package auth - -import ( - "context" - "log" - "reflect" - - "git.aiterp.net/rpdata/api/models" -) - -// CheckPermission does some magic. -func CheckPermission(ctx context.Context, op string, obj interface{}) error { - token := TokenFromContext(ctx) - if token == nil { - return ErrUnauthenticated - } - - if v := reflect.ValueOf(obj); v.Kind() == reflect.Struct { - ptr := reflect.PtrTo(v.Type()) - ptrValue := reflect.New(ptr.Elem()) - ptrValue.Elem().Set(v) - - obj = ptrValue.Interface() - } - - var authorized = false - - switch v := obj.(type) { - case *models.Channel: - authorized = token.Permitted("channel." + op) - case *models.Character: - authorized = token.PermittedUser(v.Author, "member", "character."+op) - case *models.Chapter: - authorized = token.PermittedUser(v.Author, "member", "chapter."+op) - case *models.Comment: - if op == "add" && v.Author != token.UserID { - return ErrInvalidOperation - } - - authorized = token.PermittedUser(v.Author, "member", "comment."+op) - case *models.File: - authorized = token.PermittedUser(v.Author, "member", "file."+op) - case *models.Log: - authorized = token.Permitted("log." + op) - case *models.Post: - authorized = token.Permitted("post." + op) - case *models.Story: - authorized = token.PermittedUser(v.Author, "member", "story."+op) - case *models.User: - authorized = token.Permitted("user." + op) - default: - log.Panicf("Invalid model %T: %#+v", v, v) - } - - if !authorized { - return ErrUnauthorized - } - - return nil -} diff --git a/internal/auth/token.go b/internal/auth/token.go deleted file mode 100644 index 73036c7..0000000 --- a/internal/auth/token.go +++ /dev/null @@ -1,167 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/users" - jwt "github.com/dgrijalva/jwt-go" -) - -var contextKey = &struct{ data string }{"Token Context Key"} - -// ErrNoKid is returned if the key id is missing from the jwt token header, -var ErrNoKid = errors.New("Missing \"kid\" field in token") - -// ErrKeyNotFound is returned if the key wasn't found. -var ErrKeyNotFound = errors.New("Key not found") - -// ErrInvalidClaims is returned by parseClaims if the claims cannot be parsed -var ErrInvalidClaims = errors.New("Invalid claims in token") - -// ErrExpired is returned by parseClaims if the expiry date is in the past -var ErrExpired = errors.New("Claims have already expired") - -// ErrWrongUser is returned by CheckToken if the key cannot represent this user -var ErrWrongUser = errors.New("Key is not valid for this user") - -// ErrWrongPermissions is returned by CheckToken if the key cannot claim one or more of its permissions -var ErrWrongPermissions = errors.New("User does not have these permissions") - -// ErrDeletedUser is returned by CheckToken if the key can represent this user, but the user doesn't exist. -var ErrDeletedUser = errors.New("User was not found") - -// ErrUnauthenticated is returned when the user is not authenticated -var ErrUnauthenticated = errors.New("You are not authenticated") - -// ErrUnauthorized is returned when the user doesn't have access to this resource -var ErrUnauthorized = errors.New("You are not authorized to perform this action") - -// ErrInvalidOperation is returned for operations that should never be allowed -var ErrInvalidOperation = errors.New("No permission exists for this operation") - -// TokenFromContext gets the token from context. -func TokenFromContext(ctx context.Context) *models.Token { - token, ok := ctx.Value(contextKey).(*models.Token) - if !ok { - return nil - } - - return token -} - -// RequestWithToken either returns the request, or the request with a new context that -// has the token. -func RequestWithToken(r *http.Request) *http.Request { - header := r.Header.Get("Authorization") - if header == "" { - return r - } - - if !strings.HasPrefix(header, "Bearer ") { - return r - } - - token, err := CheckToken(header[7:]) - if err != nil { - return r - } - - return r.WithContext(context.WithValue(r.Context(), contextKey, &token)) -} - -// CheckToken reads the token string and returns a token if everything is kosher. -func CheckToken(tokenString string) (token models.Token, err error) { - var key Key - - jwtToken, err := jwt.Parse(tokenString, func(jwtToken *jwt.Token) (interface{}, error) { - if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Unexpected signing method: %v", jwtToken.Header["alg"]) - } - - kid, ok := jwtToken.Header["kid"].(string) - if !ok { - return nil, ErrNoKid - } - - key, err = FindKey(kid) - if err != nil || key.ID == "" { - return nil, ErrKeyNotFound - } - - return []byte(key.Secret), nil - }) - if err != nil { - return models.Token{}, err - } - - userid, permissions, err := parseClaims(jwtToken.Claims) - if err != nil { - return models.Token{}, err - } - - if !key.ValidForUser(userid) { - return models.Token{}, ErrWrongUser - } - - user, err := users.Ensure(userid) - if err != nil { - return models.Token{}, ErrDeletedUser - } - - acceptedPermissions := make([]string, 0, len(user.Permissions)) - if len(permissions) > 0 { - for _, permission := range permissions { - found := false - - for _, userPermission := range user.Permissions { - if permission == userPermission { - found = true - break - } - } - - if found { - acceptedPermissions = append(acceptedPermissions, permission) - } - } - } else { - acceptedPermissions = append(acceptedPermissions, user.Permissions...) - } - - return models.Token{UserID: user.ID, Permissions: acceptedPermissions}, nil -} - -func parseClaims(jwtClaims jwt.Claims) (userid string, permissions []string, err error) { - mapClaims, ok := jwtClaims.(jwt.MapClaims) - if !ok { - return "", nil, ErrInvalidClaims - } - - if !mapClaims.VerifyExpiresAt(time.Now().Unix(), true) { - return "", nil, ErrExpired - } - - if userid, ok = mapClaims["user"].(string); !ok { - return "", nil, ErrInvalidClaims - } - - if claimedPermissions, ok := mapClaims["permissions"].([]interface{}); ok { - for _, permission := range claimedPermissions { - if permission, ok := permission.(string); ok { - permissions = append(permissions, permission) - } - } - - if len(permissions) == 0 { - return "", nil, ErrInvalidClaims - } - } - - return -} diff --git a/internal/counter/counter.go b/internal/counter/counter.go deleted file mode 100644 index 9695a7d..0000000 --- a/internal/counter/counter.go +++ /dev/null @@ -1,55 +0,0 @@ -package counter - -import ( - "git.aiterp.net/rpdata/api/internal/store" - "github.com/globalsign/mgo" - "github.com/globalsign/mgo/bson" -) - -var collection *mgo.Collection - -type counter struct { - ID string `bson:"_id"` - Value int `bson:"value"` -} - -// Next gets the next value of a counter, or an error if it hasn't. -func Next(category, name string) (int, error) { - id := category + "." + name - doc := counter{} - - _, err := collection.Find(bson.M{"_id": id}).Apply(mgo.Change{ - Update: bson.M{"$inc": bson.M{"value": 1}}, - Upsert: true, - ReturnNew: true, - }, &doc) - if err != nil { - return -1, err - } - - return doc.Value, nil -} - -// NextMany gets the next value of a counter, or an error if it hasn't, and increments by a specified value. -// Any value `returned` to `returned+(increment-1)` should then be safe to use. -func NextMany(category, name string, increment int) (int, error) { - id := category + "." + name - doc := counter{} - - _, err := collection.Find(bson.M{"_id": id}).Apply(mgo.Change{ - Update: bson.M{"$inc": bson.M{"value": increment}}, - Upsert: true, - ReturnNew: true, - }, &doc) - if err != nil { - return -1, err - } - - return doc.Value, nil -} - -func init() { - store.HandleInit(func(db *mgo.Database) { - collection = db.C("core.counters") - }) -} diff --git a/internal/generate/id.go b/internal/generate/id.go index 3235791..23b6787 100644 --- a/internal/generate/id.go +++ b/internal/generate/id.go @@ -62,3 +62,7 @@ func CommentID() string { func FileID() string { return ID("F", 16) } + +func KeyID() string { + return ID("K", 32) +} diff --git a/internal/loader/channel.go b/internal/loader/channel.go deleted file mode 100644 index 549f01e..0000000 --- a/internal/loader/channel.go +++ /dev/null @@ -1,78 +0,0 @@ -package loader - -import ( - "context" - "errors" - "strings" - - "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/channels" - "github.com/graph-gophers/dataloader" -) - -// Channel gets a character by key -func (loader *Loader) Channel(key, value string) (*models.Channel, error) { - if !strings.HasPrefix(key, "Channel.") { - key = "Channel." + key - } - - if loader.loaders[key] == nil { - return nil, errors.New("unsupported key") - } - - loader.loadPrimed(key) - - thunk := loader.loaders[key].Load(loader.ctx, dataloader.StringKey(value)) - res, err := thunk() - if err != nil { - return nil, err - } - - channel, ok := res.(models.Channel) - if !ok { - return nil, errors.New("incorrect type") - } - - return &channel, nil -} - -// PrimeChannels primes channels for loading along with the first one. -func (loader *Loader) PrimeChannels(key string, values ...string) { - if !strings.HasPrefix(key, "Channel.") { - key = "Channel." + key - } - - loader.prime(key, values) -} - -func channelNameBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { - var results []*dataloader.Result - names := keys.Keys() - - channels, err := channels.ListNames(names...) - if err != nil { - for range names { - results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err}) - } - - return results - } - - for _, name := range names { - found := false - for i := range channels { - if channels[i].Name == name { - results = append(results, &dataloader.Result{Data: channels[i]}) - - found = true - break - } - } - - if !found { - results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err}) - } - } - - return results -} diff --git a/internal/loader/character.go b/internal/loader/character.go deleted file mode 100644 index 151ab85..0000000 --- a/internal/loader/character.go +++ /dev/null @@ -1,146 +0,0 @@ -package loader - -import ( - "context" - "errors" - "strings" - - "git.aiterp.net/rpdata/api/models" - "git.aiterp.net/rpdata/api/models/characters" - "github.com/graph-gophers/dataloader" -) - -// Character gets a character by key -func (loader *Loader) Character(key, value string) (models.Character, error) { - if !strings.HasPrefix(key, "Character.") { - key = "Character." + key - } - - loader.loadPrimed(key) - - if loader.loaders[key] == nil { - return models.Character{}, errors.New("unsupported key") - } - - thunk := loader.loaders[key].Load(loader.ctx, dataloader.StringKey(value)) - res, err := thunk() - if err != nil { - return models.Character{}, err - } - - char, ok := res.(models.Character) - if !ok { - return models.Character{}, errors.New("incorrect type") - } - - return char, nil -} - -// Characters gets characters by key -func (loader *Loader) Characters(key string, values ...string) ([]models.Character, error) { - if !strings.HasPrefix(key, "Character.") { - key = "Character." + key - } - - if loader.loaders[key] == nil { - return nil, errors.New("unsupported key") - } - - loader.loadPrimed(key) - - thunk := loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(values)) - res, errs := thunk() - for _, err := range errs { - if err != nil && err != ErrNotFound { - return nil, err - } - } - - chars := make([]models.Character, len(res)) - - for i := range res { - char, ok := res[i].(models.Character) - if !ok { - return nil, errors.New("incorrect type") - } - - chars[i] = char - } - - return chars, nil -} - -// PrimeCharacters adds a set of characters to be loaded if, and only if, characters -// are going to be loaded. This will fill up the cache and speed up subsequent dataloader -// runs. -func (loader *Loader) PrimeCharacters(key string, values ...string) { - if !strings.HasPrefix(key, "Character.") { - key = "Character." + key - } - - loader.prime(key, values) -} - -func characterIDBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { - results := make([]*dataloader.Result, 0, len(keys)) - ids := keys.Keys() - - characters, err := characters.List(&characters.Filter{IDs: ids}) - if err != nil { - for range ids { - results = append(results, &dataloader.Result{Error: err}) - } - - return results - } - - for _, id := range ids { - found := false - - for _, character := range characters { - if character.ID == id { - results = append(results, &dataloader.Result{Data: character}) - found = true - break - } - } - - if !found { - results = append(results, &dataloader.Result{Data: models.Character{}, Error: ErrNotFound}) - } - } - - return results -} - -func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { - var results []*dataloader.Result - nicks := keys.Keys() - - characters, err := characters.List(&characters.Filter{Nicks: nicks}) - if err != nil { - for range nicks { - results = append(results, &dataloader.Result{Error: err}) - } - - return results - } - - for _, nick := range nicks { - found := false - for i := range characters { - if characters[i].HasNick(nick) { - results = append(results, &dataloader.Result{Data: characters[i]}) - - found = true - break - } - } - - if !found { - results = append(results, &dataloader.Result{Data: models.Character{}, Error: err}) - } - } - - return results -} diff --git a/internal/loader/loader.go b/internal/loader/loader.go deleted file mode 100644 index d243d84..0000000 --- a/internal/loader/loader.go +++ /dev/null @@ -1,80 +0,0 @@ -package loader - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/graph-gophers/dataloader" -) - -var contextKey struct{} - -// ErrNotFound is returned in batches when one or more things weren't found. Usually harmless. -var ErrNotFound = errors.New("not found") - -// A Loader is a collection of data loaders and functions to act on them. It's supposed to be -// request-scoped, and will thus keep things cached indefinitely. -type Loader struct { - mutex sync.Mutex - ctx context.Context - loaders map[string]*dataloader.Loader - - primedKeys map[string]map[string]bool -} - -// New initializes the loader. -func New() *Loader { - return &Loader{ - ctx: context.Background(), - loaders: map[string]*dataloader.Loader{ - "Character.id": dataloader.NewBatchedLoader(characterIDBatch, dataloader.WithWait(time.Millisecond*2)), - "Character.nick": dataloader.NewBatchedLoader(characterNickBatch, dataloader.WithWait(time.Millisecond*2)), - "Channel.name": dataloader.NewBatchedLoader(channelNameBatch, dataloader.WithWait(time.Millisecond*2)), - }, - primedKeys: make(map[string]map[string]bool), - } -} - -// FromContext gets the Loader from context. -func FromContext(ctx context.Context) *Loader { - value := ctx.Value(&contextKey) - if value == nil { - return nil - } - - return value.(*Loader) -} - -// ToContext gets a context with the loader as a value -func (loader *Loader) ToContext(ctx context.Context) context.Context { - loader.ctx = ctx - return context.WithValue(ctx, &contextKey, loader) -} - -func (loader *Loader) prime(key string, values []string) { - loader.mutex.Lock() - if loader.primedKeys[key] == nil { - loader.primedKeys[key] = make(map[string]bool) - } - - for _, value := range values { - loader.primedKeys[key][value] = true - } - loader.mutex.Unlock() -} - -func (loader *Loader) loadPrimed(key string) { - loader.mutex.Lock() - if len(loader.primedKeys[key]) > 0 { - primedKeys := make([]string, 0, len(loader.primedKeys[key])) - for key := range loader.primedKeys[key] { - primedKeys = append(primedKeys, key) - } - - loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(primedKeys)) - loader.primedKeys[key] = nil - } - loader.mutex.Unlock() -} diff --git a/internal/store/db.go b/internal/store/db.go deleted file mode 100644 index 2e034f8..0000000 --- a/internal/store/db.go +++ /dev/null @@ -1,73 +0,0 @@ -package store - -import ( - "fmt" - "time" - - "github.com/globalsign/mgo" -) - -var db *mgo.Database -var dbInits []func(db *mgo.Database) - -// ConnectDB connects to a mongodb database. -func ConnectDB(host string, port int, database, username, password, mechanism string) error { - session, err := mgo.DialWithInfo(&mgo.DialInfo{ - Addrs: []string{fmt.Sprintf("%s:%d", host, port)}, - Timeout: 30 * time.Second, - Database: database, - Username: username, - Password: password, - Mechanism: mechanism, - Source: database, - }) - if err != nil { - return err - } - - db = session.DB(database) - - return setupDB() -} - -// HandleInit handles the initialization of the database -func HandleInit(function func(db *mgo.Database)) { - dbInits = append(dbInits, function) -} - -func setupDB() error { - db.C("common.characters").EnsureIndexKey("name") - db.C("common.characters").EnsureIndexKey("shortName") - db.C("common.characters").EnsureIndexKey("author") - err := db.C("common.characters").EnsureIndex(mgo.Index{ - Key: []string{"nicks"}, - Unique: true, - DropDups: true, - }) - if err != nil { - return err - } - - db.C("logbot3.logs").EnsureIndexKey("date") - db.C("logbot3.logs").EnsureIndexKey("channel") - db.C("logbot3.logs").EnsureIndexKey("channel", "open") - db.C("logbot3.logs").EnsureIndexKey("open") - db.C("logbot3.logs").EnsureIndexKey("oldId") - db.C("logbot3.logs").EnsureIndexKey("characterIds") - db.C("logbot3.logs").EnsureIndexKey("event") - db.C("logbot3.logs").EnsureIndexKey("$text:channel", "$text:title", "$text:event", "$text:description", "$text:posts.nick", "$text:posts.text") - - err = db.C("server.changes").EnsureIndex(mgo.Index{ - Key: []string{"date"}, - ExpireAfter: time.Hour * (24 * 14), - }) - if err != nil { - return err - } - - for _, dbInit := range dbInits { - dbInit(db) - } - - return nil -} diff --git a/internal/store/init.go b/internal/store/init.go deleted file mode 100644 index 976d5cc..0000000 --- a/internal/store/init.go +++ /dev/null @@ -1,39 +0,0 @@ -package store - -import ( - "sync" - - "git.aiterp.net/rpdata/api/internal/config" -) - -var initMuted sync.Mutex -var hasInitialized bool - -// Init initalizes the store -func Init() error { - initMuted.Lock() - defer initMuted.Unlock() - if hasInitialized { - return nil - } - - conf := config.Global() - - dbconf := conf.Database - err := ConnectDB(dbconf.Host, dbconf.Port, dbconf.Db, dbconf.Username, dbconf.Password, dbconf.Mechanism) - if err != nil { - return err - } - - sconf := conf.Space - if sconf.Enabled { - err = ConnectSpace(sconf.Host, sconf.AccessKey, sconf.SecretKey, sconf.Bucket, sconf.MaxSize, sconf.Root) - if err != nil { - return err - } - } - - hasInitialized = true - - return nil -} diff --git a/internal/store/space.go b/internal/store/space.go deleted file mode 100644 index af8dd57..0000000 --- a/internal/store/space.go +++ /dev/null @@ -1,97 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "io" - - minio "github.com/minio/minio-go" -) - -var spaceBucket string -var spaceURLRoot string -var spaceRoot string -var spaceClient *minio.Client -var spaceMaxSize int64 - -// ConnectSpace connects to a S3 space. -func ConnectSpace(host, accessKey, secretKey, bucket string, maxSize int64, rootDirectory string) error { - client, err := minio.New(host, accessKey, secretKey, true) - if err != nil { - return err - } - - exists, err := client.BucketExists(bucket) - if err != nil { - return err - } - - if !exists { - return errors.New("Bucket not found") - } - - spaceClient = client - spaceBucket = bucket - spaceURLRoot = fmt.Sprintf("https://%s.%s/%s/", bucket, host, rootDirectory) - spaceMaxSize = maxSize - spaceRoot = rootDirectory - - return nil -} - -// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should -// ensure that's all okay. -func UploadFile(ctx context.Context, folder string, name string, mimeType string, reader io.Reader, size int64) (string, error) { - if spaceClient == nil { - return "", errors.New("This functionality is not enabled") - } - - path := folder + "/" + name - - if size > spaceMaxSize { - return "", errors.New("File is too big") - } - - _, err := spaceClient.PutObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, reader, size, minio.PutObjectOptions{ - ContentType: mimeType, - UserMetadata: map[string]string{ - "x-amz-acl": "public-read", - }, - }) - if err != nil { - return "", err - } - - _, err = spaceClient.StatObject(spaceBucket, spaceRoot+"/"+path, minio.StatObjectOptions{}) - if err != nil { - return "", err - } - - return path, nil -} - -// RemoveFile removes a file from the space -func RemoveFile(folder string, name string) error { - if spaceClient == nil { - return errors.New("This functionality is not enabled") - } - - path := folder + "/" + name - - return spaceClient.RemoveObject(spaceBucket, spaceRoot+"/"+path) -} - -// DownloadFile opens a file for download, using the same path format as the UploadFile function. Remember to Close it! -func DownloadFile(ctx context.Context, path string) (io.ReadCloser, error) { - if spaceClient == nil { - return nil, errors.New("This functionality is not enabled") - } - - return spaceClient.GetObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, minio.GetObjectOptions{}) -} - -// URLFromPath gets the URL from the path returned by UploadFile -func URLFromPath(path string) string { - return spaceURLRoot + path -} diff --git a/models/channels/add.go b/models/channels/add.go deleted file mode 100644 index 1103ce0..0000000 --- a/models/channels/add.go +++ /dev/null @@ -1,33 +0,0 @@ -package channels - -import ( - "errors" - "strings" - - "git.aiterp.net/rpdata/api/models" -) - -// ErrInvalidName is an error for an invalid channel name. -var ErrInvalidName = errors.New("Invalid channel name") - -// Add creates a new channel. -func Add(name string, logged, hub bool, event, location string) (models.Channel, error) { - if len(name) < 3 && !strings.HasPrefix(name, "#") { - return models.Channel{}, ErrInvalidName - } - - channel := models.Channel{ - Name: name, - Logged: logged, - Hub: hub, - EventName: event, - LocationName: location, - } - - err := collection.Insert(channel) - if err != nil { - return models.Channel{}, err - } - - return channel, nil -} diff --git a/models/channels/db.go b/models/channels/db.go deleted file mode 100644 index 860c96e..0000000 --- a/models/channels/db.go +++ /dev/null @@ -1,19 +0,0 @@ -package channels - -import ( - "git.aiterp.net/rpdata/api/internal/store" - "github.com/globalsign/mgo" -) - -var collection *mgo.Collection - -func init() { - store.HandleInit(func(db *mgo.Database) { - collection = db.C("common.channels") - - collection.EnsureIndexKey("logged") - collection.EnsureIndexKey("hub") - collection.EnsureIndexKey("event") - collection.EnsureIndexKey("location") - }) -} diff --git a/models/channels/edit.go b/models/channels/edit.go deleted file mode 100644 index 3cdf5cc..0000000 --- a/models/channels/edit.go +++ /dev/null @@ -1,39 +0,0 @@ -package channels - -import ( - "git.aiterp.net/rpdata/api/models" - "github.com/globalsign/mgo/bson" -) - -// Edit edits a channels -func Edit(channel models.Channel, logged, hub *bool, eventName, locationName *string) (models.Channel, error) { - mutation := bson.M{} - - if logged != nil && *logged != channel.Logged { - mutation["logged"] = *logged - channel.Logged = *logged - } - if hub != nil && *hub != channel.Hub { - mutation["hub"] = *hub - channel.Hub = *hub - } - if eventName != nil && *eventName != channel.EventName { - mutation["eventName"] = *eventName - channel.EventName = *eventName - } - if locationName != nil && *locationName != channel.LocationName { - mutation["locationName"] = *locationName - channel.LocationName = *locationName - } - - if len(mutation) == 0 { - return channel, nil - } - - err := collection.UpdateId(channel.Name, bson.M{"$set": mutation}) - if err != nil { - return models.Channel{}, err - } - - return channel, nil -} diff --git a/models/channels/ensure.go b/models/channels/ensure.go deleted file mode 100644 index 5eddef7..0000000 --- a/models/channels/ensure.go +++ /dev/null @@ -1,33 +0,0 @@ -package channels - -import ( - "strings" - - "github.com/globalsign/mgo" - - "git.aiterp.net/rpdata/api/models" -) - -// Ensure adds a channel if it doesn't exist. If logged is set and the found channel isn't logged, -// that is changed. -func Ensure(name string, logged bool) (models.Channel, error) { - if len(name) < 3 && !strings.HasPrefix(name, "#") { - return models.Channel{}, ErrInvalidName - } - - channel, err := FindName(name) - if err == mgo.ErrNotFound { - return Add(name, logged, false, "", "") - } else if err != nil { - return models.Channel{}, err - } - - if logged && !channel.Logged { - channel, err = Edit(channel, &logged, nil, nil, nil) - if err != nil { - return models.Channel{}, err - } - } - - return channel, nil -} diff --git a/models/channels/find.go b/models/channels/find.go deleted file mode 100644 index 50ef371..0000000 --- a/models/channels/find.go +++ /dev/null @@ -1,11 +0,0 @@ -package channels - -import "git.aiterp.net/rpdata/api/models" - -// FindName finds a channel by its id (its name). -func FindName(name string) (models.Channel, error) { - channel := models.Channel{} - err := collection.FindId(name).One(&channel) - - return channel, err -} diff --git a/models/channels/list.go b/models/channels/list.go deleted file mode 100644 index 7a9500c..0000000 --- a/models/channels/list.go +++ /dev/null @@ -1,46 +0,0 @@ -package channels - -import ( - "git.aiterp.net/rpdata/api/models" - "github.com/globalsign/mgo/bson" -) - -// Filter for searching -type Filter struct { - Logged *bool `json:"logged"` - EventName string `json:"eventName"` - LocationName string `json:"locationName"` -} - -// List finds channels, if logged is true it will be limited to logged -// channels -func List(filter *Filter) ([]models.Channel, error) { - query := bson.M{} - - if filter != nil { - if filter.Logged != nil { - query["logged"] = *filter.Logged - } - if filter.EventName != "" { - query["eventName"] = filter.EventName - } - if filter.LocationName != "" { - query["locationName"] = filter.LocationName - } - } - - channels := make([]models.Channel, 0, 128) - err := collection.Find(query).Sort("_id").All(&channels) - - return channels, err -} - -// ListNames finds channels by the names provided -func ListNames(names ...string) ([]models.Channel, error) { - query := bson.M{"_id": bson.M{"$in": names}} - - channels := make([]models.Channel, 0, 32) - err := collection.Find(query).All(&channels) - - return channels, err -} diff --git a/models/key.go b/models/key.go new file mode 100644 index 0000000..65e1fa9 --- /dev/null +++ b/models/key.go @@ -0,0 +1,22 @@ +package models + +// A Key contains a JWT secret and the limitations of it. There are two types of +// keys, single-user keys and wildcard keys. The former is used to authenticate +// a single user (e.g. the logbot) through an API while the latter is only for +// services that can be trusted to perform its own authentication (a frontend). +type Key struct { + ID string `bson:"_id"` + Name string `bson:"name"` + User string `bson:"user"` + Secret string `bson:"secret"` +} + +// ValidForUser returns true if the key's user is the same as +// the user, or it's a wildcard key. +func (key *Key) ValidForUser(user string) bool { + return key.User == user || key.User == "*" +} + +type KeyFilter struct { + UserID *string +} diff --git a/repositories/key.go b/repositories/key.go new file mode 100644 index 0000000..d29eecd --- /dev/null +++ b/repositories/key.go @@ -0,0 +1,13 @@ +package repositories + +import ( + "context" + "git.aiterp.net/rpdata/api/models" +) + +type KeyRepository interface { + Find(ctx context.Context, id string) (*models.Key, error) + List(ctx context.Context, filter models.KeyFilter) ([]*models.Key, error) + Insert(ctx context.Context, key models.Key) (*models.Key, error) + Delete(ctx context.Context, key models.Key) error +} diff --git a/repositories/user.go b/repositories/user.go new file mode 100644 index 0000000..f930d2d --- /dev/null +++ b/repositories/user.go @@ -0,0 +1,11 @@ +package repositories + +import ( + "context" + "git.aiterp.net/rpdata/api/models" +) + +type UserRepository interface { + Find(ctx context.Context, id string) (*models.User, error) + Insert(ctx context.Context, user models.User) (*models.User, error) +} diff --git a/services/auth.go b/services/auth.go new file mode 100644 index 0000000..a3fbe94 --- /dev/null +++ b/services/auth.go @@ -0,0 +1,331 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/repositories" + "github.com/dgrijalva/jwt-go" + "log" + "net/http" + "reflect" + "strings" + "time" +) + +var contextKey = &struct{ data string }{"Token Context Key"} + +// ErrNoKid is returned if the key id is missing from the jwt token header, +var ErrNoKid = errors.New("missing \"kid\" field in token") + +// ErrKeyNotFound is returned if the key wasn't found. +var ErrKeyNotFound = errors.New("key not found") + +// ErrInvalidClaims is returned by parseClaims if the claims cannot be parsed +var ErrInvalidClaims = errors.New("invalid claims in token") + +// ErrExpired is returned by parseClaims if the expiry date is in the past +var ErrExpired = errors.New("claims have already expired") + +// ErrWrongUser is returned by CheckToken if the key cannot represent this user +var ErrWrongUser = errors.New("key is not valid for this user") + +// ErrDeletedUser is returned by CheckToken if the key can represent this user, but the user doesn't exist. +var ErrDeletedUser = errors.New("user was not found") + +// ErrUnauthenticated is returned when the user is not authenticated +var ErrUnauthenticated = errors.New("you are not authenticated") + +// ErrUnauthorized is returned when the user doesn't have access to this resource +var ErrUnauthorized = errors.New("you are not authorized to perform this action") + +// ErrInvalidOperation is returned for operations that should never be allowed +var ErrInvalidOperation = errors.New("no permission exists for this operation") + +// AuthService is a service for handling the 'orizations and 'entications. +type AuthService struct { + keys repositories.KeyRepository + users repositories.UserRepository +} + +// FindKey finds a key by id. +func (s *AuthService) FindKey(ctx context.Context, id string) (*models.Key, error) { + return s.keys.Find(ctx, id) +} + +// FindKey finds a key by id. +func (s *AuthService) FindUser(ctx context.Context, id string) (*models.User, error) { + return s.users.Find(ctx, id) +} + +// CreateKey generates a new key for the user and name. This +// does not allow generating wildcard keys, they have to be +// manually inserted into the DB. +func (s *AuthService) CreateKey(ctx context.Context, name, user string) (*models.Key, error) { + if user == "*" { + return nil, errors.New("auth: wildcard keys not allowed") + } + + secret, err := s.generateSecret() + if err != nil { + return nil, err + } + + key := &models.Key{ + Name: name, + User: user, + Secret: secret, + } + + key, err = s.keys.Insert(ctx, *key) + if err != nil { + return nil, err + } + + return key, nil +} + +// CheckPermission does some magic. +func (s *AuthService) CheckPermission(ctx context.Context, op string, obj interface{}) error { + token := s.TokenFromContext(ctx) + if token == nil { + return ErrUnauthenticated + } + + if v := reflect.ValueOf(obj); v.Kind() == reflect.Struct { + ptr := reflect.PtrTo(v.Type()) + ptrValue := reflect.New(ptr.Elem()) + ptrValue.Elem().Set(v) + + obj = ptrValue.Interface() + } + + var authorized = false + + switch v := obj.(type) { + case *models.Channel: + authorized = token.Permitted("channel." + op) + case *models.Character: + authorized = token.PermittedUser(v.Author, "member", "character."+op) + case *models.Chapter: + authorized = token.PermittedUser(v.Author, "member", "chapter."+op) + case *models.Comment: + if op == "add" && v.Author != token.UserID { + return ErrInvalidOperation + } + + authorized = token.PermittedUser(v.Author, "member", "comment."+op) + case *models.File: + authorized = token.PermittedUser(v.Author, "member", "file."+op) + case *models.Log: + authorized = token.Permitted("log." + op) + case *models.Post: + authorized = token.Permitted("post." + op) + case *models.Story: + authorized = token.PermittedUser(v.Author, "member", "story."+op) + case *models.User: + authorized = token.Permitted("user." + op) + default: + log.Panicf("Invalid model %T: %#+v", v, v) + } + + if !authorized { + return ErrUnauthorized + } + + return nil +} + +// TokenFromContext gets the token from context. +func (s *AuthService) TokenFromContext(ctx context.Context) *models.Token { + token, ok := ctx.Value(contextKey).(*models.Token) + if !ok { + return nil + } + + return token +} + +// RequestWithToken either returns the request, or the request with a new context that +// has the token. +func (s *AuthService) RequestWithToken(r *http.Request) *http.Request { + header := r.Header.Get("Authorization") + if header == "" { + return r + } + + if !strings.HasPrefix(header, "Bearer ") { + return r + } + + token, err := s.CheckToken(r.Context(), header[7:]) + if err != nil { + return r + } + + return r.WithContext(context.WithValue(r.Context(), contextKey, &token)) +} + +// CheckToken reads the token string and returns a token if everything is kosher. +func (s *AuthService) CheckToken(ctx context.Context, tokenString string) (token models.Token, err error) { + var key *models.Key + + jwtToken, err := jwt.Parse(tokenString, func(jwtToken *jwt.Token) (interface{}, error) { + if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", jwtToken.Header["alg"]) + } + + kid, ok := jwtToken.Header["kid"].(string) + if !ok { + return nil, ErrNoKid + } + + key, err = s.FindKey(ctx, kid) + if err != nil || key.ID == "" { + return nil, ErrKeyNotFound + } + + return []byte(key.Secret), nil + }) + if err != nil { + return models.Token{}, err + } + + userid, permissions, err := s.parseClaims(jwtToken.Claims) + if err != nil { + return models.Token{}, err + } + + if !key.ValidForUser(userid) { + return models.Token{}, ErrWrongUser + } + + user, err := s.ensureUser(ctx, userid) + if err != nil { + return models.Token{}, ErrDeletedUser + } + + acceptedPermissions := make([]string, 0, len(user.Permissions)) + if len(permissions) > 0 { + for _, permission := range permissions { + found := false + + for _, userPermission := range user.Permissions { + if permission == userPermission { + found = true + break + } + } + + if found { + acceptedPermissions = append(acceptedPermissions, permission) + } + } + } else { + acceptedPermissions = append(acceptedPermissions, user.Permissions...) + } + + return models.Token{UserID: user.ID, Permissions: acceptedPermissions}, nil +} + +// AllPermissions gets all permissions and their purpose +func (s *AuthService) AllPermissions() map[string]string { + return map[string]string{ + "member": "Can add/edit/remove own content", + "user.edit": "Can edit non-owned users", + "character.add": "Can add non-owned characters", + "character.edit": "Can edit non-owned characters", + "character.remove": "Can remove non-owned characters", + "channel.add": "Can add channels", + "channel.edit": "Can edit channels", + "channel.remove": "Can remove channels", + "comment.edit": "Can edit non-owned comments", + "comment.remove": "Can remove non-owned comments", + "log.add": "Can add logs", + "log.edit": "Can edit logs", + "log.remove": "Can remove logs", + "post.add": "Can add posts", + "post.edit": "Can edit posts", + "post.move": "Can move posts", + "post.remove": "Can remove posts", + "story.add": "Can add non-owned stories", + "story.edit": "Can edit non-owned stories", + "story.remove": "Can remove non-owned stories", + "story.unlisted": "Can view non-owned unlisted stories", + "file.upload": "Can upload files", + "file.list": "Can list non-owned files", + "file.view": "Can view non-owned files", + "file.edit": "Can edit non-owned files", + "file.remove": "Can remove non-owned files", + } +} + +func (s *AuthService) parseClaims(jwtClaims jwt.Claims) (userid string, permissions []string, err error) { + mapClaims, ok := jwtClaims.(jwt.MapClaims) + if !ok { + return "", nil, ErrInvalidClaims + } + + if !mapClaims.VerifyExpiresAt(time.Now().Unix(), true) { + return "", nil, ErrExpired + } + + if userid, ok = mapClaims["user"].(string); !ok { + return "", nil, ErrInvalidClaims + } + + if claimedPermissions, ok := mapClaims["permissions"].([]interface{}); ok { + for _, permission := range claimedPermissions { + if permission, ok := permission.(string); ok { + permissions = append(permissions, permission) + } + } + + if len(permissions) == 0 { + return "", nil, ErrInvalidClaims + } + } + + return +} + +func (s *AuthService) generateSecret() (string, error) { + var data [64]byte + + _, err := rand.Read(data[:]) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(data[:]), nil +} + +func (s *AuthService) ensureUser(ctx context.Context, id string) (*models.User, error) { + user, err := s.users.Find(ctx, id) + if err == repositories.ErrNotFound { + user = &models.User{ + ID: id, + Nick: "", + Permissions: []string{ + "member", + "log.edit", + "post.edit", + "post.move", + "post.remove", + "file.upload", + }, + } + + user, err = s.users.Insert(ctx, *user) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + return user, err +} diff --git a/services/changes.go b/services/changes.go index 83c9186..c34677a 100644 --- a/services/changes.go +++ b/services/changes.go @@ -2,7 +2,6 @@ package services import ( "context" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/internal/notifier" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/repositories" @@ -12,7 +11,8 @@ import ( ) type ChangeService struct { - changes repositories.ChangeRepository + changes repositories.ChangeRepository + authService *AuthService mutex sync.RWMutex buffer []models.Change @@ -31,7 +31,7 @@ func (s *ChangeService) List(ctx context.Context, filter models.ChangeFilter) ([ } func (s *ChangeService) Submit(ctx context.Context, model models.ChangeModel, op string, listed bool, keys []models.ChangeKey, objects ...interface{}) { - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if token == nil { panic("no token!") } @@ -127,15 +127,16 @@ func (s *ChangeService) Subscribe(ctx context.Context, filter models.ChangeFilte func (s *ChangeService) loop() { for change := range s.submitQueue { - timeout, cancel := context.WithTimeout(context.Background(), time.Second*15) + timeout, cancel := context.WithTimeout(context.Background(), time.Second*5) change, err := s.changes.Insert(timeout, *change) if err != nil { log.Println("Failed to insert change:") - } else { - log.Println("Change", change.ID, "inserted.") + continue } + log.Println("Change", change.ID, "inserted.") + s.mutex.Lock() s.buffer = append(s.buffer, *change) if len(s.buffer) > 16 { diff --git a/services/channels.go b/services/channels.go index b1d11b4..7ced7b1 100644 --- a/services/channels.go +++ b/services/channels.go @@ -2,7 +2,6 @@ package services import ( "context" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/changekeys" "git.aiterp.net/rpdata/api/repositories" @@ -14,6 +13,7 @@ type ChannelService struct { channels repositories.ChannelRepository loader *loaders.ChannelLoader changeService *ChangeService + authService *AuthService } func (s *ChannelService) Find(ctx context.Context, id string) (*models.Channel, error) { @@ -44,7 +44,7 @@ func (s *ChannelService) List(ctx context.Context, filter models.ChannelFilter) } func (s *ChannelService) Create(ctx context.Context, name string, logged, hub bool, eventName, locationName string) (*models.Channel, error) { - err := auth.CheckPermission(ctx, "add", &models.Channel{}) + err := s.authService.CheckPermission(ctx, "add", &models.Channel{}) if err != nil { return nil, err } @@ -91,7 +91,7 @@ func (s *ChannelService) Update(ctx context.Context, name string, update models. return nil, err } - err = auth.CheckPermission(ctx, "edit", channel) + err = s.authService.CheckPermission(ctx, "edit", channel) if err != nil { return nil, err } @@ -114,7 +114,7 @@ func (s *ChannelService) Delete(ctx context.Context, name string) (*models.Chann return nil, err } - err = auth.CheckPermission(ctx, "remove", channel) + err = s.authService.CheckPermission(ctx, "remove", channel) if err != nil { return nil, err } diff --git a/services/characters.go b/services/characters.go index 0663ab2..fe7f627 100644 --- a/services/characters.go +++ b/services/characters.go @@ -3,7 +3,6 @@ package services import ( "context" "errors" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/changekeys" "git.aiterp.net/rpdata/api/repositories" @@ -16,6 +15,7 @@ type CharacterService struct { characters repositories.CharacterRepository loader *loaders.CharacterLoader changeService *ChangeService + authService *AuthService } // Find uses the loader to find the character by the ID. @@ -65,9 +65,9 @@ func (s *CharacterService) List(ctx context.Context, filter models.CharacterFilt } func (s *CharacterService) Create(ctx context.Context, nick, name, shortName, author, description string) (*models.Character, error) { - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if token == nil { - return nil, auth.ErrUnauthenticated + return nil, ErrUnauthenticated } if name == "" { @@ -91,7 +91,7 @@ func (s *CharacterService) Create(ctx context.Context, nick, name, shortName, au Description: description, } - err := auth.CheckPermission(ctx, "add", character) + err := s.authService.CheckPermission(ctx, "add", character) if err != nil { return nil, err } @@ -112,7 +112,7 @@ func (s *CharacterService) Update(ctx context.Context, id string, name, shortNam return nil, err } - err = auth.CheckPermission(ctx, "edit", character) + err = s.authService.CheckPermission(ctx, "edit", character) if err != nil { return nil, err } @@ -140,7 +140,7 @@ func (s *CharacterService) AddNick(ctx context.Context, id string, nick string) return nil, err } - err = auth.CheckPermission(ctx, "edit", character) + err = s.authService.CheckPermission(ctx, "edit", character) if err != nil { return nil, err } @@ -164,7 +164,7 @@ func (s *CharacterService) RemoveNick(ctx context.Context, id string, nick strin return nil, err } - err = auth.CheckPermission(ctx, "edit", character) + err = s.authService.CheckPermission(ctx, "edit", character) if err != nil { return nil, err } @@ -188,7 +188,7 @@ func (s *CharacterService) Delete(ctx context.Context, id string) (*models.Chara return nil, err } - err = auth.CheckPermission(ctx, "edit", character) + err = s.authService.CheckPermission(ctx, "edit", character) if err != nil { return nil, err } diff --git a/services/files.go b/services/files.go index c6eb450..d3cf040 100644 --- a/services/files.go +++ b/services/files.go @@ -3,7 +3,6 @@ package services import ( "context" "errors" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/repositories" "github.com/h2non/filetype" @@ -19,7 +18,8 @@ var ErrCouldNotUploadFile = errors.New("could not upload file") // FileService is a service for files. type FileService struct { - files repositories.FileRepository + files repositories.FileRepository + authService *AuthService } func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) { @@ -29,7 +29,7 @@ func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) } if !file.Public { - err := auth.CheckPermission(ctx, "view", file) + err := s.authService.CheckPermission(ctx, "view", file) if err != nil { return nil, repositories.ErrNotFound } @@ -39,7 +39,7 @@ func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) } func (s *FileService) List(ctx context.Context, filter models.FileFilter) ([]*models.File, error) { - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if filter.Public != nil { if *filter.Public == false { @@ -48,7 +48,7 @@ func (s *FileService) List(ctx context.Context, filter models.FileFilter) ([]*mo } if !token.PermittedUser(*filter.Author, "member", "file.list") { - return nil, auth.ErrUnauthorized + return nil, ErrUnauthorized } } } @@ -83,7 +83,7 @@ func (s *FileService) Edit(ctx context.Context, id string, name *string, public return nil, err } - err = auth.CheckPermission(ctx, "edit", file) + err = s.authService.CheckPermission(ctx, "edit", file) if err != nil { if !file.Public { return nil, repositories.ErrNotFound diff --git a/services/logs.go b/services/logs.go index e60953f..9cdbb10 100644 --- a/services/logs.go +++ b/services/logs.go @@ -3,10 +3,8 @@ package services import ( "context" "errors" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/changekeys" - "git.aiterp.net/rpdata/api/models/channels" "git.aiterp.net/rpdata/api/repositories" "git.aiterp.net/rpdata/api/services/parsers" "golang.org/x/sync/errgroup" @@ -23,6 +21,7 @@ type LogService struct { changeService *ChangeService channelService *ChannelService characterService *CharacterService + authService *AuthService unknownNicks map[string]int unknownNicksMutex sync.Mutex @@ -93,7 +92,7 @@ func (s *LogService) Create(ctx context.Context, title, description, channelName Open: open, } - if err := auth.CheckPermission(ctx, "add", log); err != nil { + if err := s.authService.CheckPermission(ctx, "add", log); err != nil { return nil, err } @@ -114,7 +113,7 @@ func (s *LogService) Create(ctx context.Context, title, description, channelName // Import creates new logs from common formats. func (s *LogService) Import(ctx context.Context, importer models.LogImporter, date time.Time, tz *time.Location, channelName string, data string) ([]*models.Log, error) { - if err := auth.CheckPermission(ctx, "add", &models.Log{}); err != nil { + if err := s.authService.CheckPermission(ctx, "add", &models.Log{}); err != nil { return nil, err } @@ -126,7 +125,7 @@ func (s *LogService) Import(ctx context.Context, importer models.LogImporter, da } eventName := "" - if channel, err := channels.FindName(channelName); err == nil { + if channel, err := s.channelService.Find(ctx, channelName); err == nil { eventName = channel.EventName } @@ -212,7 +211,7 @@ func (s *LogService) Update(ctx context.Context, id string, update models.LogUpd return nil, err } - if err := auth.CheckPermission(ctx, "edit", log); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", log); err != nil { return nil, err } @@ -232,7 +231,7 @@ func (s *LogService) SplitLog(ctx context.Context, logId string, startPostId str if err != nil { return nil, err } - if err := auth.CheckPermission(ctx, "add", l); err != nil { + if err := s.authService.CheckPermission(ctx, "add", l); err != nil { return nil, err } @@ -314,11 +313,11 @@ func (s *LogService) SplitLog(ctx context.Context, logId string, startPostId str func (s *LogService) MergeLogs(ctx context.Context, targetID string, sourceID string, removeAfter bool) (*models.Log, error) { // Check permissions - if err := auth.CheckPermission(ctx, "edit", &models.Log{}); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", &models.Log{}); err != nil { return nil, err } if removeAfter { - if err := auth.CheckPermission(ctx, "remove", &models.Log{}); err != nil { + if err := s.authService.CheckPermission(ctx, "remove", &models.Log{}); err != nil { return nil, err } } @@ -393,7 +392,7 @@ func (s *LogService) AddPost(ctx context.Context, logId string, time time.Time, Time: time, } - if err := auth.CheckPermission(ctx, "add", post); err != nil { + if err := s.authService.CheckPermission(ctx, "add", post); err != nil { return nil, err } @@ -424,7 +423,7 @@ func (s *LogService) EditPost(ctx context.Context, id string, update models.Post return nil, err } - if err := auth.CheckPermission(ctx, "edit", post); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", post); err != nil { return nil, err } @@ -460,7 +459,7 @@ func (s *LogService) MovePost(ctx context.Context, id string, position int) ([]* return nil, err } - if err := auth.CheckPermission(ctx, "move", post); err != nil { + if err := s.authService.CheckPermission(ctx, "move", post); err != nil { return nil, err } @@ -491,7 +490,7 @@ func (s *LogService) DeletePost(ctx context.Context, id string) (*models.Post, e return nil, err } - if err := auth.CheckPermission(ctx, "remove", post); err != nil { + if err := s.authService.CheckPermission(ctx, "remove", post); err != nil { return nil, err } @@ -523,7 +522,7 @@ func (s *LogService) Delete(ctx context.Context, id string) (*models.Log, error) return nil, err } - if err := auth.CheckPermission(ctx, "remove", log); err != nil { + if err := s.authService.CheckPermission(ctx, "remove", log); err != nil { return nil, err } diff --git a/services/services.go b/services/services.go index 946063c..c9bdb69 100644 --- a/services/services.go +++ b/services/services.go @@ -13,24 +13,37 @@ type Bundle struct { Logs *LogService Channels *ChannelService Stories *StoryService + Auth *AuthService + Files *FileService } // NewBundle creates a new bundle. func NewBundle(db database.Database) *Bundle { bundle := &Bundle{} + bundle.Auth = &AuthService{ + keys: db.Keys(), + users: db.Users(), + } + bundle.Files = &FileService{ + files: nil, + authService: bundle.Auth, + } bundle.Changes = &ChangeService{ - changes: db.Changes(), + changes: db.Changes(), + authService: bundle.Auth, } bundle.Tags = &TagService{tags: db.Tags()} bundle.Characters = &CharacterService{ characters: db.Characters(), loader: loaders.CharacterLoaderFromRepository(db.Characters()), changeService: bundle.Changes, + authService: bundle.Auth, } bundle.Channels = &ChannelService{ channels: db.Channels(), loader: loaders.ChannelLoaderFromRepository(db.Channels()), changeService: bundle.Changes, + authService: bundle.Auth, } bundle.Logs = &LogService{ logs: db.Logs(), @@ -38,6 +51,7 @@ func NewBundle(db database.Database) *Bundle { changeService: bundle.Changes, channelService: bundle.Channels, characterService: bundle.Characters, + authService: bundle.Auth, unknownNicks: make(map[string]int, 512), } @@ -47,6 +61,7 @@ func NewBundle(db database.Database) *Bundle { comments: db.Comments(), changeService: bundle.Changes, characterService: bundle.Characters, + authService: bundle.Auth, } return bundle diff --git a/services/stories.go b/services/stories.go index cabd7b9..9bfd2cd 100644 --- a/services/stories.go +++ b/services/stories.go @@ -3,7 +3,6 @@ package services import ( "context" "errors" - "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/internal/generate" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/changekeys" @@ -18,6 +17,7 @@ type StoryService struct { comments repositories.CommentRepository changeService *ChangeService characterService *CharacterService + authService *AuthService } func (s *StoryService) FindStory(ctx context.Context, id string) (*models.Story, error) { @@ -46,9 +46,9 @@ func (s *StoryService) ListComments(ctx context.Context, chapter models.Chapter, func (s *StoryService) CreateStory(ctx context.Context, name string, author *string, category models.StoryCategory, listed, open bool, tags []models.Tag, createdDate, fictionalDate time.Time) (*models.Story, error) { if author == nil { - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if token == nil { - return nil, auth.ErrUnauthenticated + return nil, ErrUnauthenticated } author = &token.UserID @@ -66,7 +66,7 @@ func (s *StoryService) CreateStory(ctx context.Context, name string, author *str UpdatedDate: createdDate, } - if err := auth.CheckPermission(ctx, "add", story); err != nil { + if err := s.authService.CheckPermission(ctx, "add", story); err != nil { return nil, err } @@ -82,9 +82,9 @@ func (s *StoryService) CreateStory(ctx context.Context, name string, author *str func (s *StoryService) CreateChapter(ctx context.Context, story models.Story, title, source string, author *string, createdDate time.Time, fictionalDate *time.Time, commentMode models.ChapterCommentMode) (*models.Chapter, error) { if author == nil { - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if token == nil { - return nil, auth.ErrUnauthenticated + return nil, ErrUnauthenticated } author = &token.UserID @@ -106,11 +106,11 @@ func (s *StoryService) CreateChapter(ctx context.Context, story models.Story, ti } if story.Open { - if !auth.TokenFromContext(ctx).Permitted("member", "chapter.add") { - return nil, auth.ErrUnauthorized + if !s.authService.TokenFromContext(ctx).Permitted("member", "chapter.add") { + return nil, ErrUnauthorized } } else { - if err := auth.CheckPermission(ctx, "add", chapter); err != nil { + if err := s.authService.CheckPermission(ctx, "add", chapter); err != nil { return nil, err } } @@ -143,10 +143,10 @@ func (s *StoryService) CreateComment(ctx context.Context, chapter models.Chapter } if author == "" { - if token := auth.TokenFromContext(ctx); token != nil { + if token := s.authService.TokenFromContext(ctx); token != nil { author = token.UserID } else { - return nil, auth.ErrUnauthenticated + return nil, ErrUnauthenticated } } @@ -162,7 +162,7 @@ func (s *StoryService) CreateComment(ctx context.Context, chapter models.Chapter EditedDate: createdDate, Source: source, } - if err := auth.CheckPermission(ctx, "add", comment); err != nil { + if err := s.authService.CheckPermission(ctx, "add", comment); err != nil { return nil, err } @@ -185,7 +185,7 @@ func (s *StoryService) EditStory(ctx context.Context, story *models.Story, name panic("StoryService.Edit called with nil story") } - if err := auth.CheckPermission(ctx, "edit", story); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", story); err != nil { return nil, err } @@ -206,7 +206,7 @@ func (s *StoryService) EditStory(ctx context.Context, story *models.Story, name } func (s *StoryService) AddStoryTag(ctx context.Context, story models.Story, tag models.Tag) (*models.Story, error) { - if err := auth.CheckPermission(ctx, "edit", &story); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", &story); err != nil { return nil, err } @@ -223,7 +223,7 @@ func (s *StoryService) AddStoryTag(ctx context.Context, story models.Story, tag } func (s *StoryService) RemoveStoryTag(ctx context.Context, story models.Story, tag models.Tag) (*models.Story, error) { - if err := auth.CheckPermission(ctx, "edit", &story); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", &story); err != nil { return nil, err } @@ -249,7 +249,7 @@ func (s *StoryService) EditChapter(ctx context.Context, chapter *models.Chapter, panic("StoryService.EditChapter called with nil chapter") } - if err := auth.CheckPermission(ctx, "edit", chapter); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", chapter); err != nil { return nil, err } @@ -274,16 +274,16 @@ func (s *StoryService) EditChapter(ctx context.Context, chapter *models.Chapter, } func (s *StoryService) MoveChapter(ctx context.Context, chapter *models.Chapter, from, to models.Story) (*models.Chapter, error) { - if err := auth.CheckPermission(ctx, "move", chapter); err != nil { + if err := s.authService.CheckPermission(ctx, "move", chapter); err != nil { return nil, err } if to.Open { - if !auth.TokenFromContext(ctx).Permitted("member", "chapter.add") { - return nil, auth.ErrUnauthorized + if !s.authService.TokenFromContext(ctx).Permitted("member", "chapter.add") { + return nil, ErrUnauthorized } } else { - if err := auth.CheckPermission(ctx, "add", chapter); err != nil { + if err := s.authService.CheckPermission(ctx, "add", chapter); err != nil { return nil, err } } @@ -304,7 +304,7 @@ func (s *StoryService) EditComment(ctx context.Context, comment *models.Comment, panic("StoryService.EditChapter called with nil chapter") } - if err := auth.CheckPermission(ctx, "edit", comment); err != nil { + if err := s.authService.CheckPermission(ctx, "edit", comment); err != nil { return nil, err } @@ -343,7 +343,7 @@ func (s *StoryService) EditComment(ctx context.Context, comment *models.Comment, } func (s *StoryService) RemoveStory(ctx context.Context, story *models.Story) error { - if err := auth.CheckPermission(ctx, "add", story); err != nil { + if err := s.authService.CheckPermission(ctx, "add", story); err != nil { return err } @@ -358,7 +358,7 @@ func (s *StoryService) RemoveStory(ctx context.Context, story *models.Story) err } func (s *StoryService) RemoveChapter(ctx context.Context, chapter *models.Chapter) error { - if err := auth.CheckPermission(ctx, "remove", chapter); err != nil { + if err := s.authService.CheckPermission(ctx, "remove", chapter); err != nil { return err } @@ -377,7 +377,7 @@ func (s *StoryService) RemoveChapter(ctx context.Context, chapter *models.Chapte } func (s *StoryService) RemoveComment(ctx context.Context, comment *models.Comment) error { - if err := auth.CheckPermission(ctx, "remove", comment); err != nil { + if err := s.authService.CheckPermission(ctx, "remove", comment); err != nil { return err } @@ -409,7 +409,7 @@ func (s *StoryService) permittedCharacter(ctx context.Context, permissionKind, c return errors.New("character could not be found") } - token := auth.TokenFromContext(ctx) + token := s.authService.TokenFromContext(ctx) if character.Author != token.UserID && !token.Permitted(permissionKind+".edit") { return errors.New("you are not permitted to use others' character") }