Gisle Aune
5 years ago
41 changed files with 617 additions and 1240 deletions
-
9cmd/rpdata-server/main.go
-
2database/database.go
-
24database/mongodb/db.go
-
69database/mongodb/keys.go
-
41database/mongodb/users.go
-
3graph2/complexity.go
-
2graph2/gqlgen.yml
-
2graph2/graph.go
-
39graph2/resolvers/file.go
-
5graph2/resolvers/token.go
-
2graph2/schema/types/File.gql
-
17graph2/types/token.go
-
109internal/auth/key.go
-
33internal/auth/permissions.go
-
60internal/auth/permitted.go
-
167internal/auth/token.go
-
55internal/counter/counter.go
-
4internal/generate/id.go
-
78internal/loader/channel.go
-
146internal/loader/character.go
-
80internal/loader/loader.go
-
73internal/store/db.go
-
39internal/store/init.go
-
97internal/store/space.go
-
33models/channels/add.go
-
19models/channels/db.go
-
39models/channels/edit.go
-
33models/channels/ensure.go
-
11models/channels/find.go
-
46models/channels/list.go
-
22models/key.go
-
13repositories/key.go
-
11repositories/user.go
-
331services/auth.go
-
11services/changes.go
-
8services/channels.go
-
16services/characters.go
-
10services/files.go
-
27services/logs.go
-
15services/services.go
-
50services/stories.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) |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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", |
|||
} |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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") |
|||
}) |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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() |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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") |
|||
}) |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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) |
|||
} |
@ -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 |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue