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