Merge branch 'user-tokens' into 'master'
User tokens See merge request !94
This commit is contained in:
commit
1593af9fde
@ -4,8 +4,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@ -17,6 +20,8 @@ var (
|
||||
ErrInvalidSecret = fmt.Errorf("Invalid secret")
|
||||
// ErrInvalidToken returned when the jwt token is invalid
|
||||
ErrInvalidToken = fmt.Errorf("Invalid token")
|
||||
// ErrUnauthenticatedUser returned when a user is not authenticated
|
||||
ErrUnauthenticatedUser = fmt.Errorf("Unauthenticated user")
|
||||
)
|
||||
|
||||
// UserBackend interface for user backend
|
||||
@ -35,6 +40,7 @@ type User interface {
|
||||
|
||||
// Authorizer handle sesssion
|
||||
type Authorizer struct {
|
||||
db *sqlx.DB
|
||||
Params
|
||||
}
|
||||
|
||||
@ -48,8 +54,9 @@ type Params struct {
|
||||
|
||||
// New Authorizer pepper is like a salt but not stored in database,
|
||||
// cost is the bcrypt cost for hashing the password
|
||||
func New(params Params) *Authorizer {
|
||||
func New(db *sqlx.DB, params Params) *Authorizer {
|
||||
return &Authorizer{
|
||||
db: db,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
@ -64,7 +71,7 @@ func (a *Authorizer) GenHash(password string) (string, error) {
|
||||
}
|
||||
|
||||
// Login cheks password and creates a jwt token
|
||||
func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username, password string) (User, error) {
|
||||
func (a *Authorizer) Login(r *http.Request, username, password string) (*tokens.Token, error) {
|
||||
u, err := a.Backend.GetUser(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -76,7 +83,35 @@ func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username,
|
||||
return nil, ErrInvalidPassword
|
||||
}
|
||||
|
||||
return u, nil
|
||||
// Create a jwt token
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
// Not before
|
||||
"nbf": time.Now().Unix(),
|
||||
// Issued at
|
||||
"iat": time.Now().Unix(),
|
||||
// Private claims
|
||||
"username": u.GetName(),
|
||||
"isAdmin": u.IsAdmin(),
|
||||
"isActivated": u.IsActivated(),
|
||||
})
|
||||
|
||||
// Sign the token
|
||||
ss, err := jwtToken.SignedString([]byte(a.Secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &tokens.Token{
|
||||
Token: ss,
|
||||
Username: u.GetName(),
|
||||
IP: getIPFromRequest(r),
|
||||
}
|
||||
|
||||
if err := t.Add(a.db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CurrentUser returns the logged in username from session and verifies the token
|
||||
@ -92,14 +127,14 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
|
||||
if tokenStr == "" {
|
||||
tokenCookie, err := req.Cookie("token")
|
||||
if err != nil || tokenCookie == nil {
|
||||
return nil, nil
|
||||
return nil, ErrUnauthenticatedUser
|
||||
}
|
||||
tokenStr = tokenCookie.Value
|
||||
}
|
||||
|
||||
// No user logged
|
||||
if tokenStr == "" {
|
||||
return nil, nil
|
||||
return nil, ErrUnauthenticatedUser
|
||||
}
|
||||
|
||||
// Keyfunc to decode the token
|
||||
@ -111,13 +146,13 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
|
||||
Username string `json:"username"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc)
|
||||
jwtToken, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check the token validity
|
||||
if !token.Valid {
|
||||
if !jwtToken.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
@ -127,5 +162,17 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check the token in database
|
||||
token, err := tokens.GetUserToken(a.db, u.GetName(), tokenStr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
token.UserAgent = req.UserAgent()
|
||||
token.IP = getIPFromRequest(req)
|
||||
if err := token.Update(a.db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
@ -2,19 +2,21 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type ipContextKey string
|
||||
type authContextKey string
|
||||
|
||||
// Middleware get User from session and put it in context
|
||||
type Middleware struct {
|
||||
authorizer *Authorizer
|
||||
log *logrus.Entry
|
||||
}
|
||||
|
||||
type authContextKey string
|
||||
|
||||
// NewMiddleware returns a new authentication middleware
|
||||
func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware {
|
||||
return &Middleware{
|
||||
@ -25,20 +27,21 @@ func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware {
|
||||
|
||||
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
user, err := m.authorizer.CurrentUser(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
switch err {
|
||||
case nil:
|
||||
m.log.Debugf("setting user %s in the context", user.GetName())
|
||||
} else {
|
||||
m.log.Debugf("got a nil user in the context")
|
||||
}
|
||||
|
||||
ctxKey := authContextKey("auth.user")
|
||||
ctx := context.WithValue(r.Context(), ctxKey, user)
|
||||
r = r.WithContext(ctx)
|
||||
case ErrUnauthenticatedUser:
|
||||
m.log.Debugf("unauthenticated user")
|
||||
case ErrInvalidToken:
|
||||
m.log.Debugf("user has an invalid token")
|
||||
default:
|
||||
m.log.Error(err)
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
@ -100,3 +103,57 @@ func GetCurrentUser(r *http.Request, log *logrus.Entry) User {
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// IPMiddleware set the IP in the request context
|
||||
type IPMiddleware struct {
|
||||
log *logrus.Entry
|
||||
}
|
||||
|
||||
// NewIPMiddleware returns a new ip middleware
|
||||
func NewIPMiddleware(log *logrus.Entry) *IPMiddleware {
|
||||
return &IPMiddleware{
|
||||
log: log.WithField("middleware", "ip"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IPMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
ip := getRequestIP(r)
|
||||
|
||||
ctxKey := ipContextKey("ip")
|
||||
ctx := context.WithValue(r.Context(), ctxKey, ip)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
func getRequestIP(req *http.Request) string {
|
||||
// Try to get the IP from this header
|
||||
var ip = req.Header.Get("X-Real-IP")
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
// Or from this one
|
||||
ip = req.Header.Get("X-Forwarded-For")
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
// fake result
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
hostIP := net.ParseIP(host)
|
||||
if host == "" {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
// Default to the IP from the request
|
||||
return hostIP.String()
|
||||
}
|
||||
|
||||
func getIPFromRequest(r *http.Request) string {
|
||||
return r.Context().Value(ipContextKey("ip")).(string)
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ func main() {
|
||||
Cost: cf.Authorizer.Cost,
|
||||
Secret: cf.Authorizer.Secret,
|
||||
}
|
||||
authorizer := auth.New(authParams)
|
||||
authorizer := auth.New(db, authParams)
|
||||
|
||||
// Create web environment needed by the app
|
||||
env := web.NewEnv(web.EnvParams{
|
||||
@ -89,6 +89,8 @@ func main() {
|
||||
defer c.Stop()
|
||||
|
||||
n := negroni.Classic()
|
||||
// Middleware for setting ips
|
||||
n.Use(auth.NewIPMiddleware(log))
|
||||
// Middleware for authentication
|
||||
n.Use(authMiddleware)
|
||||
// Serve static files
|
||||
|
@ -1,38 +0,0 @@
|
||||
package random
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
const (
|
||||
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||
)
|
||||
|
||||
var src = rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// String returns n random strings
|
||||
func String(n int) string {
|
||||
b := make([]byte, n)
|
||||
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
|
||||
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
|
||||
if remain == 0 {
|
||||
cache, remain = src.Int63(), letterIdxMax
|
||||
}
|
||||
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
|
||||
b[i] = letterBytes[idx]
|
||||
i--
|
||||
}
|
||||
cache >>= letterIdxBits
|
||||
remain--
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
@ -16,6 +16,9 @@ func setupRoutes(env *web.Env) {
|
||||
env.Handle("/users/signup", users.SignupPOSTHandler).Methods("POST")
|
||||
env.Handle("/users/details", users.DetailsHandler).WithRole(users.UserRole).Methods("GET")
|
||||
env.Handle("/users/edit", users.EditHandler).WithRole(users.UserRole).Methods("POST")
|
||||
env.Handle("/users/tokens", users.GetTokensHandler).WithRole(users.UserRole).Methods("GET")
|
||||
env.Handle("/users/tokens/{token}", users.EditTokenHandler).WithRole(users.UserRole).Methods("POST")
|
||||
env.Handle("/users/tokens/{token}", users.DeleteTokenHandler).WithRole(users.UserRole).Methods("DELETE")
|
||||
|
||||
// Movies routes
|
||||
env.Handle("/movies/polochon", movies.PolochonMoviesHandler).WithRole(users.UserRole).Methods("GET")
|
||||
|
99
backend/tokens/tokens.go
Normal file
99
backend/tokens/tokens.go
Normal file
@ -0,0 +1,99 @@
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/sqly"
|
||||
)
|
||||
|
||||
const (
|
||||
addTokenQuery = `INSERT INTO tokens (token, username, ip) VALUES ($1, $2, $3);`
|
||||
getTokenQuery = `SELECT * FROM tokens WHERE token=$1;`
|
||||
getUserTokenQuery = `SELECT * FROM tokens WHERE username=$1 and token=$2;`
|
||||
getUserTokensQuery = `SELECT * FROM tokens WHERE username=$1;`
|
||||
deleteTokenQuery = `DELETE FROM tokens WHERE username=$1 AND token=$2;`
|
||||
updateTokenQuery = `UPDATE tokens SET description=:description, user_agent=:user_agent, ip=:ip, last_used=now() WHERE token=:token RETURNING *;`
|
||||
)
|
||||
|
||||
// Custom errors
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("tokens: token not found")
|
||||
)
|
||||
|
||||
// Token represents a token
|
||||
type Token struct {
|
||||
sqly.BaseModel
|
||||
Username string `db:"username" json:"username"`
|
||||
Token string `db:"token" json:"token"`
|
||||
Description string `db:"description" json:"description"`
|
||||
UserAgent string `db:"user_agent" json:"user_agent"`
|
||||
IP string `db:"ip" json:"ip"`
|
||||
LastUsed time.Time `db:"last_used" json:"last_used"`
|
||||
}
|
||||
|
||||
// Add a token to the database
|
||||
func (t *Token) Add(db *sqlx.DB) error {
|
||||
_, err := db.Queryx(addTokenQuery, t.Token, t.Username, t.IP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserToken returns the token linked to a user
|
||||
func GetUserToken(db *sqlx.DB, username, token string) (*Token, error) {
|
||||
t := &Token{}
|
||||
err := db.QueryRowx(getUserTokenQuery, username, token).StructScan(t)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteToken deletes a token
|
||||
func DeleteToken(db *sqlx.DB, username, token string) error {
|
||||
r, err := db.Exec(deleteTokenQuery, username, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count, err := r.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
return fmt.Errorf("Unexpected number of row deleted: %d", count)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserTokens returns all tokens owned by the user
|
||||
func GetUserTokens(db *sqlx.DB, username string) ([]*Token, error) {
|
||||
tokens := []*Token{}
|
||||
err := db.Select(&tokens, getUserTokensQuery, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// Update updates a token
|
||||
func (t *Token) Update(db *sqlx.DB) error {
|
||||
rows, err := db.NamedQuery(updateTokenQuery, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
rows.StructScan(t)
|
||||
}
|
||||
return nil
|
||||
}
|
@ -4,12 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/auth"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/web"
|
||||
)
|
||||
|
||||
@ -65,37 +64,21 @@ func LoginPOSTHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := e.Auth.Login(w, r, data.Username, data.Password)
|
||||
token, err := e.Auth.Login(r, data.Username, data.Password)
|
||||
if err != nil {
|
||||
if err == auth.ErrInvalidPassword || err == ErrUnknownUser {
|
||||
return e.RenderError(w, fmt.Errorf("Error invalid user or password"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
e.Log.Debugf("logged %s", user.GetName())
|
||||
e.Log.Debugf("logged %s", token.Username)
|
||||
|
||||
// Create a jwt token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
// Not before
|
||||
"nbf": time.Now().Unix(),
|
||||
// Issued at
|
||||
"iat": time.Now().Unix(),
|
||||
// Private claims
|
||||
"username": user.GetName(),
|
||||
"isAdmin": user.IsAdmin(),
|
||||
"isActivated": user.IsActivated(),
|
||||
})
|
||||
|
||||
// Sign the token
|
||||
ss, err := token.SignedString([]byte(e.Auth.Secret))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO: add token expiration / keep me login stuff
|
||||
|
||||
var out = struct {
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: ss,
|
||||
Token: token.Token,
|
||||
}
|
||||
|
||||
return e.RenderJSON(w, out)
|
||||
@ -167,3 +150,68 @@ func EditHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
return e.RenderOK(w, "user updated")
|
||||
}
|
||||
|
||||
// GetTokensHandler lists the tokens of a user
|
||||
func GetTokensHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error {
|
||||
v := auth.GetCurrentUser(r, e.Log)
|
||||
user, ok := v.(*User)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid user type")
|
||||
}
|
||||
|
||||
tokens, err := tokens.GetUserTokens(e.Database, user.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.RenderJSON(w, tokens)
|
||||
}
|
||||
|
||||
// DeleteTokenHandler helps delete a token
|
||||
func DeleteTokenHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
token := vars["token"]
|
||||
|
||||
v := auth.GetCurrentUser(r, e.Log)
|
||||
user, ok := v.(*User)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid user type")
|
||||
}
|
||||
|
||||
if err := tokens.DeleteToken(e.Database, user.Name, token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.RenderOK(w, "token deleted")
|
||||
}
|
||||
|
||||
// EditTokenHandler helps delete a token
|
||||
func EditTokenHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
token := vars["token"]
|
||||
|
||||
v := auth.GetCurrentUser(r, e.Log)
|
||||
user, ok := v.(*User)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid user type")
|
||||
}
|
||||
|
||||
t, err := tokens.GetUserToken(e.Database, user.Name, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Description string `json:"description"`
|
||||
}{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Description = data.Description
|
||||
if err := t.Update(e.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.RenderJSON(w, t)
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ import (
|
||||
"github.com/odwrtw/papi"
|
||||
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/random"
|
||||
"gitlab.quimbo.fr/odwrtw/canape/backend/sqly"
|
||||
)
|
||||
|
||||
@ -22,11 +21,6 @@ const (
|
||||
updateUserQuery = `UPDATE users SET name=:name, hash=:hash, admin=:admin, activated=:activated, rawconfig=:rawconfig WHERE id=:id RETURNING *;`
|
||||
deleteUseQuery = `DELETE FROM users WHERE id=:id;`
|
||||
|
||||
addTokenQuery = `INSERT INTO tokens (value, user_id) VALUES ($1, $2) RETURNING id;`
|
||||
getTokensQuery = `SELECT id, value FROM tokens WHERE user_id=$1;`
|
||||
checkTokenQuery = `SELECT count(*) FROM tokens WHERE user_id=$1 AND value=$2;`
|
||||
deleteTokenQuery = `DELETE FROM tokens WHERE user_id=$1 AND value=$2;`
|
||||
|
||||
getAllUsersQuery = `SELECT * FROM users order by created_at;`
|
||||
)
|
||||
|
||||
@ -119,12 +113,6 @@ func (u *User) NewPapiClient() (*papi.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Token represents a token
|
||||
type Token struct {
|
||||
sqly.BaseModel
|
||||
Value string
|
||||
}
|
||||
|
||||
// Get returns user with specified name
|
||||
func Get(q sqlx.Queryer, name string) (*User, error) {
|
||||
u := &User{}
|
||||
@ -193,57 +181,6 @@ func (u *User) Delete(ex *sqlx.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokens returns all tokens owned by the user
|
||||
func (u *User) GetTokens(ex *sqlx.DB) ([]*Token, error) {
|
||||
tokens := []*Token{}
|
||||
err := ex.Select(&tokens, getTokensQuery, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// NewToken generates a new token for the user
|
||||
func (u *User) NewToken(ex *sqlx.DB) (*Token, error) {
|
||||
t := &Token{
|
||||
Value: random.String(50),
|
||||
}
|
||||
|
||||
var id string
|
||||
err := ex.QueryRowx(addTokenQuery, t.Value, u.ID).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ID = id
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CheckToken checks if specified value exists in token's values for the user
|
||||
func (u *User) CheckToken(ex *sqlx.DB, value string) (bool, error) {
|
||||
var count int
|
||||
err := ex.QueryRowx(checkTokenQuery, u.ID, value).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteToken delete token by value
|
||||
func (u *User) DeleteToken(ex *sqlx.DB, value string) error {
|
||||
r, err := ex.Exec(deleteTokenQuery, u.ID, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := r.RowsAffected()
|
||||
if count != 1 {
|
||||
return fmt.Errorf("Unexpected number of row deleted: %d", count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHash implements auth.User interface
|
||||
func (u *User) GetHash() string {
|
||||
return u.Hash
|
||||
|
@ -50,3 +50,20 @@ export function getUserInfos() {
|
||||
configureAxios().get("/users/details")
|
||||
)
|
||||
}
|
||||
|
||||
export function getUserTokens() {
|
||||
return request(
|
||||
"GET_USER_TOKENS",
|
||||
configureAxios().get("/users/tokens")
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteUserToken(token) {
|
||||
return request(
|
||||
"DELETE_USER_TOKEN",
|
||||
configureAxios().delete(`/users/tokens/${token}`),
|
||||
[
|
||||
() => getUserTokens(),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
@ -164,6 +164,11 @@ function UserDropdown(props) {
|
||||
<MenuItem>Edit</MenuItem>
|
||||
</LinkContainer>
|
||||
}
|
||||
{props.isActivated &&
|
||||
<LinkContainer to="/users/tokens">
|
||||
<MenuItem>Tokens</MenuItem>
|
||||
</LinkContainer>
|
||||
}
|
||||
<LinkContainer to="/users/logout">
|
||||
<MenuItem>Logout</MenuItem>
|
||||
</LinkContainer>
|
||||
|
187
frontend/js/components/users/tokens.js
Normal file
187
frontend/js/components/users/tokens.js
Normal file
@ -0,0 +1,187 @@
|
||||
import React from "react"
|
||||
import { connect } from "react-redux"
|
||||
import { UAParser } from "ua-parser-js"
|
||||
import moment from "moment"
|
||||
|
||||
import { bindActionCreators } from "redux"
|
||||
import { deleteUserToken } from "../../actions/users"
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({ deleteUserToken }, dispatch)
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
tokens: state.userStore.get("tokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function UserTokens(props) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="content-fluid">
|
||||
<TokenList {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenList(props) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hidden-xs">Tokens</h2>
|
||||
<h3 className="visible-xs">Tokens</h3>
|
||||
|
||||
<div>
|
||||
{props.tokens.map(function(el, index) {
|
||||
return (
|
||||
<Token
|
||||
key={index}
|
||||
data={el}
|
||||
deleteToken={props.deleteUserToken}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Token(props) {
|
||||
const ua = UAParser(props.data.get("user_agent"));
|
||||
return (
|
||||
<div className="panel panel-default">
|
||||
<div className="container">
|
||||
<Logo {...ua} />
|
||||
<div className="col-xs-10">
|
||||
<dl className="dl-horizontal">
|
||||
<dt>Description</dt>
|
||||
<dd>{props.data.get("description")}</dd>
|
||||
|
||||
<dt>Last IP</dt>
|
||||
<dd>{props.data.get("ip")}</dd>
|
||||
|
||||
<dt>Last used</dt>
|
||||
<dd>{moment(props.data.get("last_used")).fromNow()}</dd>
|
||||
|
||||
<dt>Created</dt>
|
||||
<dd>{moment(props.data.get("created_at")).fromNow()}</dd>
|
||||
|
||||
<dt>Device</dt>
|
||||
<dd><Device {...ua.device}/></dd>
|
||||
|
||||
<dt>OS</dt>
|
||||
<dd><OS {...ua.os}/></dd>
|
||||
|
||||
<dt>Browser</dt>
|
||||
<dd><Browser {...ua.browser}/></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<Actions {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class Actions extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
handleClick() {
|
||||
const token = this.props.data.get("token");
|
||||
this.props.deleteToken(token);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div className="col-xs-1">
|
||||
<span
|
||||
className="fa fa-trash fa-lg pull-right clickable user-token-action"
|
||||
onClick={this.handleClick}>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Logo(props) {
|
||||
var className;
|
||||
if (props.ua === "canape-cli") {
|
||||
className = "terminal";
|
||||
} else if (props.device.type == "mobile" ){
|
||||
className = "mobile";
|
||||
} else {
|
||||
switch (props.browser.name) {
|
||||
case "Chrome":
|
||||
case "chrome":
|
||||
className = "chrome";
|
||||
break;
|
||||
case "Safari":
|
||||
case "safari":
|
||||
className = "safari";
|
||||
break;
|
||||
case "Firefox":
|
||||
case "firefox":
|
||||
className = "firefox";
|
||||
break;
|
||||
default:
|
||||
className = "question";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-token-icon">
|
||||
<div className="hidden-xs hidden-sm col-md-1">
|
||||
<span className={"fa fa-" + className + " fa-5x"}></span>
|
||||
</div>
|
||||
<div className="hidden-md hidden-lg col-xs-1">
|
||||
<span className={"fa fa-" + className + " fa-lg"}></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OS(props) {
|
||||
var osName = "-";
|
||||
|
||||
if (props.name !== undefined) {
|
||||
osName = props.name;
|
||||
|
||||
if (props.version !== undefined) {
|
||||
osName += " " + props.version;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span> {osName}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Device(props) {
|
||||
var deviceName = "-";
|
||||
|
||||
if (props.model !== undefined) {
|
||||
deviceName = props.model;
|
||||
}
|
||||
|
||||
return (
|
||||
<span> {deviceName}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Browser(props) {
|
||||
var browserName = "-";
|
||||
if (props.name !== undefined) {
|
||||
browserName = props.name;
|
||||
|
||||
if (props.version !== undefined) {
|
||||
browserName += " - " + props.version;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span> {browserName}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UserTokens);
|
@ -1,4 +1,4 @@
|
||||
import { Map } from "immutable"
|
||||
import { Map, List, fromJS } from "immutable"
|
||||
|
||||
import jwtDecode from "jwt-decode"
|
||||
import Cookies from "universal-cookie"
|
||||
@ -11,6 +11,7 @@ const defaultState = Map({
|
||||
isLogged: false,
|
||||
polochonToken: "",
|
||||
polochonUrl: "",
|
||||
tokens: List(),
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
@ -27,6 +28,9 @@ const handlers = {
|
||||
polochonToken: action.payload.response.data.token,
|
||||
polochonUrl: action.payload.response.data.url,
|
||||
})),
|
||||
"GET_USER_TOKENS_FULFILLED": (state, action) => state.set(
|
||||
"tokens", fromJS(action.payload.response.data)
|
||||
),
|
||||
}
|
||||
|
||||
function logoutUser() {
|
||||
|
@ -3,6 +3,7 @@ import ShowList from "./components/shows/list"
|
||||
import ShowDetails from "./components/shows/details"
|
||||
import UserLoginForm from "./components/users/login"
|
||||
import UserEdit from "./components/users/edit"
|
||||
import UserTokens from "./components/users/tokens"
|
||||
import UserActivation from "./components/users/activation"
|
||||
import UserSignUp from "./components/users/signup"
|
||||
import TorrentList from "./components/torrents/list"
|
||||
@ -10,7 +11,7 @@ import TorrentSearch from "./components/torrents/search"
|
||||
import AdminPanel from "./components/admins/panel"
|
||||
|
||||
import { fetchTorrents, searchTorrents } from "./actions/torrents"
|
||||
import { userLogout, getUserInfos } from "./actions/users"
|
||||
import { userLogout, getUserInfos, getUserTokens } from "./actions/users"
|
||||
import { fetchMovies, getMovieExploreOptions } from "./actions/movies"
|
||||
import { fetchShows, fetchShowDetails, getShowExploreOptions } from "./actions/shows"
|
||||
import { getUsers, getStats } from "./actions/admins"
|
||||
@ -118,6 +119,15 @@ export default function getRoutes(App) {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/users/tokens",
|
||||
component: UserTokens,
|
||||
onEnter: function(nextState, replace, next) {
|
||||
loginCheck(nextState, replace, next, function() {
|
||||
store.dispatch(getUserTokens());
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/users/activation",
|
||||
component: UserActivation,
|
||||
|
@ -53,6 +53,7 @@ body {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.spaced-icons,
|
||||
.episode-buttons {
|
||||
div, span {
|
||||
margin: 2px;
|
||||
@ -103,3 +104,11 @@ table.torrent-search-result {
|
||||
table.table-align-middle > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.user-token-icon > div {
|
||||
padding-top: 1%;
|
||||
}
|
||||
|
||||
span.user-token-action {
|
||||
margin: 10px;
|
||||
}
|
||||
|
1
migrations/0006_user_token.down.sql
Normal file
1
migrations/0006_user_token.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE tokens;
|
12
migrations/0006_user_token.up.sql
Normal file
12
migrations/0006_user_token.up.sql
Normal file
@ -0,0 +1,12 @@
|
||||
CREATE TABLE tokens (
|
||||
token text PRIMARY KEY NOT NULL UNIQUE,
|
||||
username text NOT NULL REFERENCES users(name),
|
||||
description text NOT NULL DEFAULT '-',
|
||||
user_agent text NOT NULL DEFAULT '-',
|
||||
ip inet NOT NULL,
|
||||
last_used timestamp with time zone NOT NULL DEFAULT current_timestamp,
|
||||
LIKE base INCLUDING DEFAULTS
|
||||
);
|
||||
|
||||
CREATE INDEX ON tokens (username);
|
||||
CREATE TRIGGER update_tokens_updated_at BEFORE UPDATE ON tokens FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
@ -17,6 +17,7 @@
|
||||
"immutable": "^3.8.1",
|
||||
"jquery": "^2.2.4",
|
||||
"jwt-decode": "^2.1.0",
|
||||
"moment": "^2.20.1",
|
||||
"react": "^16.2.0",
|
||||
"react-bootstrap": "^0.32.1",
|
||||
"react-bootstrap-sweetalert": "^4.2.3",
|
||||
@ -31,6 +32,7 @@
|
||||
"redux": "^3.7.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"ua-parser-js": "^0.7.17",
|
||||
"universal-cookie": "^2.1.2",
|
||||
"webpack": "^3.11.0"
|
||||
},
|
||||
|
@ -2975,6 +2975,10 @@ minimist@^1.2.0:
|
||||
dependencies:
|
||||
minimist "0.0.8"
|
||||
|
||||
moment@^2.20.1:
|
||||
version "2.20.1"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
@ -4399,7 +4403,7 @@ typedarray@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
|
||||
ua-parser-js@^0.7.9:
|
||||
ua-parser-js@^0.7.17, ua-parser-js@^0.7.9:
|
||||
version "0.7.17"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user