You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
331 lines
8.9 KiB
331 lines
8.9 KiB
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
|
|
}
|