Gisle Aune
6 years ago
36 changed files with 442 additions and 408 deletions
-
16cmd/rpdata-graphiql/main.go
-
8graphql/resolver/mutations/addChannel.go
-
12graphql/resolver/mutations/addChapter.go
-
12graphql/resolver/mutations/addCharacter.go
-
10graphql/resolver/mutations/addCharacterNick.go
-
8graphql/resolver/mutations/addLog.go
-
8graphql/resolver/mutations/addPost.go
-
12graphql/resolver/mutations/addStory.go
-
10graphql/resolver/mutations/addStoryTag.go
-
8graphql/resolver/mutations/editChannel.go
-
10graphql/resolver/mutations/editChapter.go
-
10graphql/resolver/mutations/editCharacter.go
-
8graphql/resolver/mutations/editFile.go
-
8graphql/resolver/mutations/editLog.go
-
8graphql/resolver/mutations/editPost.go
-
10graphql/resolver/mutations/editStory.go
-
8graphql/resolver/mutations/movePost.go
-
8graphql/resolver/mutations/removeChannel.go
-
8graphql/resolver/mutations/removeChapter.go
-
10graphql/resolver/mutations/removeCharacter.go
-
10graphql/resolver/mutations/removeCharacterNick.go
-
8graphql/resolver/mutations/removeFile.go
-
8graphql/resolver/mutations/removeLog.go
-
8graphql/resolver/mutations/removePost.go
-
10graphql/resolver/mutations/removeStory.go
-
10graphql/resolver/mutations/removeStoryTag.go
-
8graphql/resolver/queries/files.go
-
11graphql/resolver/queries/stories.go
-
16graphql/resolver/types/session.go
-
16graphql/resolver/types/user.go
-
109internal/auth/key.go
-
198internal/auth/token.go
-
4internal/auth/user.go
-
20internal/session/context.go
-
40internal/session/defaults.go
-
182internal/session/session.go
@ -1,16 +0,0 @@ |
|||||
package types |
|
||||
|
|
||||
import "git.aiterp.net/rpdata/api/internal/session" |
|
||||
|
|
||||
// SessionResolver resolves Session
|
|
||||
type SessionResolver struct{ S *session.Session } |
|
||||
|
|
||||
// User resolves Session.user
|
|
||||
func (r *SessionResolver) User() *UserResolver { |
|
||||
user := r.S.User() |
|
||||
if user == nil { |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
return &UserResolver{U: user} |
|
||||
} |
|
@ -1,16 +0,0 @@ |
|||||
package types |
|
||||
|
|
||||
import "git.aiterp.net/rpdata/api/internal/session" |
|
||||
|
|
||||
// UserResolver resulves the user type
|
|
||||
type UserResolver struct{ U *session.User } |
|
||||
|
|
||||
// ID resolves User.id
|
|
||||
func (r *UserResolver) ID() string { |
|
||||
return r.U.ID |
|
||||
} |
|
||||
|
|
||||
// Permissions resolves User.permissions
|
|
||||
func (r *UserResolver) Permissions() []string { |
|
||||
return r.U.Permissions |
|
||||
} |
|
@ -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,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) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
Write
Preview
Loading…
Cancel
Save
Reference in new issue