@@ -0,0 +1,109 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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("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")
|
||||
|
||||
// A Token contains the parsed results from an bearer token. Its methods are safe to use with a nil receiver, but
|
||||
// the userID should be checked.
|
||||
type Token struct {
|
||||
UserID string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
// Authenticated returns true if the token is non-nil and parsed
|
||||
func (token *Token) Authenticated() bool {
|
||||
return token != nil && token.UserID != ""
|
||||
}
|
||||
|
||||
// Permitted returns true if the token is non-nil and has the given permission or the "admin" permission
|
||||
func (token *Token) Permitted(permissions ...string) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, tokenPermission := range token.Permissions {
|
||||
if tokenPermission == "admin" {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
if permission == tokenPermission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PermittedUser checks the first permission if the user matches, the second otherwise. This is a common
|
||||
// pattern.
|
||||
func (token *Token) PermittedUser(userID, permissionIfUser, permissionOtherwise string) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if token.UserID == userID {
|
||||
return token.Permitted(permissionIfUser)
|
||||
}
|
||||
|
||||
return token.Permitted(permissionOtherwise)
|
||||
}
|
||||
|
||||
// TokenFromContext gets the token from context.
|
||||
func TokenFromContext(ctx context.Context) *Token {
|
||||
token, ok := ctx.Value(contextKey).(*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 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 Token{}, err
|
||||
}
|
||||
|
||||
userid, permissions, err := parseClaims(jwtToken.Claims)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
|
||||
if !key.ValidForUser(userid) {
|
||||
return Token{}, ErrWrongUser
|
||||
}
|
||||
|
||||
user, err := FindUser(userid)
|
||||
if err != nil {
|
||||
return Token{}, ErrDeletedUser
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
found := false
|
||||
|
||||
for _, userPermission := range user.Permissions {
|
||||
if permission == userPermission {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Token{}, ErrWrongPermissions
|
||||
}
|
||||
}
|
||||
|
||||
return Token{UserID: token.UserID, Permissions: permissions}, 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,4 +1,4 @@
|
||||
package session
|
||||
package auth
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
@@ -16,7 +16,7 @@ type User struct {
|
||||
|
||||
// Permitted returns true if either of the permissions can be found
|
||||
//
|
||||
// `user.ID == page.Author || user.Permitted("story.edit")`
|
||||
// `token.UserID == page.Author || token.Permitted("story.edit")`
|
||||
func (user *User) Permitted(permissions ...string) bool {
|
||||
for i := range permissions {
|
||||
for j := range user.Permissions {
|
||||
@@ -1,20 +0,0 @@
|
||||
package session
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKeyType struct{ name string }
|
||||
|
||||
func (ck *contextKeyType) String() string {
|
||||
return ck.name
|
||||
}
|
||||
|
||||
var contextKey = &contextKeyType{name: "session context key"}
|
||||
|
||||
// FromContext gets a session fron the context.
|
||||
func FromContext(ctx context.Context) *Session {
|
||||
return ctx.Value(contextKey).(*Session)
|
||||
}
|
||||
|
||||
func contextWithSession(parent context.Context, session *Session) context.Context {
|
||||
return context.WithValue(parent, contextKey, session)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package session
|
||||
|
||||
// DefaultPermissions gets the default permissions
|
||||
func DefaultPermissions() []string {
|
||||
return []string{
|
||||
"member",
|
||||
"log.edit",
|
||||
"log.reorder",
|
||||
"post.edit",
|
||||
"post.move",
|
||||
"file.upload",
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
"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.edit": "Can edit non-owned stories",
|
||||
"story.remove": "Can remove non-owned stories",
|
||||
"story.unlisted": "Can view unlisted stories by other users",
|
||||
"file.upload": "Can upload files",
|
||||
"file.edit": "Can edit non-owned files",
|
||||
"file.remove": "Can remove non-owned files",
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/aiterp/wikiauth"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/config"
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var sessionCollection *mgo.Collection
|
||||
|
||||
// A Session represents a login session.
|
||||
type Session struct {
|
||||
mutex sync.Mutex
|
||||
|
||||
ID string `bson:"_id"`
|
||||
Time time.Time `bson:"time"`
|
||||
UserID string `bson:"userId"`
|
||||
|
||||
user *User
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Load loads a session from a cookie, returning either `r` or a request
|
||||
// with the session context.
|
||||
func Load(w http.ResponseWriter, r *http.Request) *http.Request {
|
||||
cookie, err := r.Cookie("aiterp_session")
|
||||
if err != nil {
|
||||
return r.WithContext(contextWithSession(r.Context(), &Session{w: w}))
|
||||
}
|
||||
|
||||
id := cookie.Value
|
||||
|
||||
session := Session{}
|
||||
err = sessionCollection.FindId(id).One(&session)
|
||||
if err != nil || time.Since(session.Time) > time.Hour*168 {
|
||||
return r.WithContext(contextWithSession(r.Context(), &Session{w: w}))
|
||||
}
|
||||
|
||||
if session.ID != "" && time.Since(session.Time) > time.Second*30 {
|
||||
session.Time = time.Now()
|
||||
go sessionCollection.UpdateId(id, bson.M{"$set": bson.M{"time": session.Time}})
|
||||
}
|
||||
|
||||
cookie.Expires = time.Now().Add(time.Hour * 168)
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
session.w = w
|
||||
|
||||
return r.WithContext(contextWithSession(r.Context(), &session))
|
||||
}
|
||||
|
||||
// Login logs a user in.
|
||||
func (session *Session) Login(username, password string) error {
|
||||
auth := wikiauth.New(config.Global().Wiki.URL)
|
||||
|
||||
err := auth.Login(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Allow bot passwords
|
||||
username = strings.SplitN(username, "@", 2)[0]
|
||||
|
||||
data := make([]byte, 32)
|
||||
_, err = rand.Read(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.ID = hex.EncodeToString(data)
|
||||
session.UserID = username
|
||||
session.Time = time.Now()
|
||||
|
||||
err = sessionCollection.Insert(&session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(session.w, &http.Cookie{
|
||||
Name: "aiterp_session",
|
||||
Value: session.ID,
|
||||
Expires: time.Now().Add(time.Hour * 2160), // 90 days
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
user, err := FindUser(session.UserID)
|
||||
if err == mgo.ErrNotFound {
|
||||
user = User{ID: username, Nick: "", Permissions: DefaultPermissions()}
|
||||
|
||||
err := userCollection.Insert(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout logs out the session
|
||||
func (session *Session) Logout() {
|
||||
http.SetCookie(session.w, &http.Cookie{
|
||||
Name: "aiterp_session",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
session.mutex.Lock()
|
||||
session.user = nil
|
||||
session.UserID = ""
|
||||
session.ID = ""
|
||||
session.mutex.Unlock()
|
||||
|
||||
sessionCollection.RemoveId(session.ID)
|
||||
}
|
||||
|
||||
// User gets the user information for the session.
|
||||
func (session *Session) User() *User {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
|
||||
if session.user != nil {
|
||||
return session.user
|
||||
}
|
||||
|
||||
if session.UserID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := FindUser(session.UserID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &user
|
||||
}
|
||||
|
||||
// NameOrPermitted is a shorthand for checking the username OR permissions, e.g. to check
|
||||
// if a logged in user can edit a certain post.
|
||||
func (session *Session) NameOrPermitted(userid string, permissions ...string) bool {
|
||||
if session.UserID == userid {
|
||||
return true
|
||||
}
|
||||
|
||||
user := session.User()
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.Permitted()
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
sessionCollection = db.C("core.sessions")
|
||||
|
||||
sessionCollection.EnsureIndexKey("nick")
|
||||
sessionCollection.EnsureIndexKey("userId")
|
||||
|
||||
err := sessionCollection.EnsureIndex(mgo.Index{
|
||||
Name: "time",
|
||||
Key: []string{"time"},
|
||||
ExpireAfter: time.Hour * 168,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user