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"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
jwt "github.com/dgrijalva/jwt-go"
|
jwt "github.com/dgrijalva/jwt-go"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@ -17,6 +20,8 @@ var (
|
|||||||
ErrInvalidSecret = fmt.Errorf("Invalid secret")
|
ErrInvalidSecret = fmt.Errorf("Invalid secret")
|
||||||
// ErrInvalidToken returned when the jwt token is invalid
|
// ErrInvalidToken returned when the jwt token is invalid
|
||||||
ErrInvalidToken = fmt.Errorf("Invalid token")
|
ErrInvalidToken = fmt.Errorf("Invalid token")
|
||||||
|
// ErrUnauthenticatedUser returned when a user is not authenticated
|
||||||
|
ErrUnauthenticatedUser = fmt.Errorf("Unauthenticated user")
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserBackend interface for user backend
|
// UserBackend interface for user backend
|
||||||
@ -35,6 +40,7 @@ type User interface {
|
|||||||
|
|
||||||
// Authorizer handle sesssion
|
// Authorizer handle sesssion
|
||||||
type Authorizer struct {
|
type Authorizer struct {
|
||||||
|
db *sqlx.DB
|
||||||
Params
|
Params
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +54,9 @@ type Params struct {
|
|||||||
|
|
||||||
// New Authorizer pepper is like a salt but not stored in database,
|
// New Authorizer pepper is like a salt but not stored in database,
|
||||||
// cost is the bcrypt cost for hashing the password
|
// cost is the bcrypt cost for hashing the password
|
||||||
func New(params Params) *Authorizer {
|
func New(db *sqlx.DB, params Params) *Authorizer {
|
||||||
return &Authorizer{
|
return &Authorizer{
|
||||||
|
db: db,
|
||||||
Params: params,
|
Params: params,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,7 +71,7 @@ func (a *Authorizer) GenHash(password string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login cheks password and creates a jwt token
|
// 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)
|
u, err := a.Backend.GetUser(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -76,7 +83,35 @@ func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username,
|
|||||||
return nil, ErrInvalidPassword
|
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
|
// 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 == "" {
|
if tokenStr == "" {
|
||||||
tokenCookie, err := req.Cookie("token")
|
tokenCookie, err := req.Cookie("token")
|
||||||
if err != nil || tokenCookie == nil {
|
if err != nil || tokenCookie == nil {
|
||||||
return nil, nil
|
return nil, ErrUnauthenticatedUser
|
||||||
}
|
}
|
||||||
tokenStr = tokenCookie.Value
|
tokenStr = tokenCookie.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// No user logged
|
// No user logged
|
||||||
if tokenStr == "" {
|
if tokenStr == "" {
|
||||||
return nil, nil
|
return nil, ErrUnauthenticatedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyfunc to decode the token
|
// Keyfunc to decode the token
|
||||||
@ -111,13 +146,13 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc)
|
jwtToken, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the token validity
|
// Check the token validity
|
||||||
if !token.Valid {
|
if !jwtToken.Valid {
|
||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,5 +162,17 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
|
|||||||
return nil, err
|
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
|
return u, nil
|
||||||
}
|
}
|
||||||
|
@ -2,19 +2,21 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ipContextKey string
|
||||||
|
type authContextKey string
|
||||||
|
|
||||||
// Middleware get User from session and put it in context
|
// Middleware get User from session and put it in context
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
authorizer *Authorizer
|
authorizer *Authorizer
|
||||||
log *logrus.Entry
|
log *logrus.Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
type authContextKey string
|
|
||||||
|
|
||||||
// NewMiddleware returns a new authentication middleware
|
// NewMiddleware returns a new authentication middleware
|
||||||
func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware {
|
func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware {
|
||||||
return &Middleware{
|
return &Middleware{
|
||||||
@ -25,21 +27,22 @@ func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware {
|
|||||||
|
|
||||||
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
user, err := m.authorizer.CurrentUser(w, r)
|
user, err := m.authorizer.CurrentUser(w, r)
|
||||||
if err != nil {
|
switch err {
|
||||||
|
case nil:
|
||||||
|
m.log.Debugf("setting user %s in the context", user.GetName())
|
||||||
|
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)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user != 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)
|
|
||||||
|
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,3 +103,57 @@ func GetCurrentUser(r *http.Request, log *logrus.Entry) User {
|
|||||||
}
|
}
|
||||||
return 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,
|
Cost: cf.Authorizer.Cost,
|
||||||
Secret: cf.Authorizer.Secret,
|
Secret: cf.Authorizer.Secret,
|
||||||
}
|
}
|
||||||
authorizer := auth.New(authParams)
|
authorizer := auth.New(db, authParams)
|
||||||
|
|
||||||
// Create web environment needed by the app
|
// Create web environment needed by the app
|
||||||
env := web.NewEnv(web.EnvParams{
|
env := web.NewEnv(web.EnvParams{
|
||||||
@ -89,6 +89,8 @@ func main() {
|
|||||||
defer c.Stop()
|
defer c.Stop()
|
||||||
|
|
||||||
n := negroni.Classic()
|
n := negroni.Classic()
|
||||||
|
// Middleware for setting ips
|
||||||
|
n.Use(auth.NewIPMiddleware(log))
|
||||||
// Middleware for authentication
|
// Middleware for authentication
|
||||||
n.Use(authMiddleware)
|
n.Use(authMiddleware)
|
||||||
// Serve static files
|
// 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/signup", users.SignupPOSTHandler).Methods("POST")
|
||||||
env.Handle("/users/details", users.DetailsHandler).WithRole(users.UserRole).Methods("GET")
|
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/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
|
// Movies routes
|
||||||
env.Handle("/movies/polochon", movies.PolochonMoviesHandler).WithRole(users.UserRole).Methods("GET")
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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/auth"
|
||||||
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
||||||
|
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
|
||||||
"gitlab.quimbo.fr/odwrtw/canape/backend/web"
|
"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
|
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 != nil {
|
||||||
if err == auth.ErrInvalidPassword || err == ErrUnknownUser {
|
if err == auth.ErrInvalidPassword || err == ErrUnknownUser {
|
||||||
return e.RenderError(w, fmt.Errorf("Error invalid user or password"))
|
return e.RenderError(w, fmt.Errorf("Error invalid user or password"))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
e.Log.Debugf("logged %s", user.GetName())
|
e.Log.Debugf("logged %s", token.Username)
|
||||||
|
|
||||||
// Create a jwt token
|
// TODO: add token expiration / keep me login stuff
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var out = struct {
|
var out = struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}{
|
}{
|
||||||
Token: ss,
|
Token: token.Token,
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.RenderJSON(w, out)
|
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")
|
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"
|
"github.com/odwrtw/papi"
|
||||||
|
|
||||||
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
"gitlab.quimbo.fr/odwrtw/canape/backend/config"
|
||||||
"gitlab.quimbo.fr/odwrtw/canape/backend/random"
|
|
||||||
"gitlab.quimbo.fr/odwrtw/canape/backend/sqly"
|
"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 *;`
|
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;`
|
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;`
|
getAllUsersQuery = `SELECT * FROM users order by created_at;`
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -119,12 +113,6 @@ func (u *User) NewPapiClient() (*papi.Client, error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token represents a token
|
|
||||||
type Token struct {
|
|
||||||
sqly.BaseModel
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns user with specified name
|
// Get returns user with specified name
|
||||||
func Get(q sqlx.Queryer, name string) (*User, error) {
|
func Get(q sqlx.Queryer, name string) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
@ -193,57 +181,6 @@ func (u *User) Delete(ex *sqlx.DB) error {
|
|||||||
return nil
|
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
|
// GetHash implements auth.User interface
|
||||||
func (u *User) GetHash() string {
|
func (u *User) GetHash() string {
|
||||||
return u.Hash
|
return u.Hash
|
||||||
|
@ -50,3 +50,20 @@ export function getUserInfos() {
|
|||||||
configureAxios().get("/users/details")
|
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>
|
<MenuItem>Edit</MenuItem>
|
||||||
</LinkContainer>
|
</LinkContainer>
|
||||||
}
|
}
|
||||||
|
{props.isActivated &&
|
||||||
|
<LinkContainer to="/users/tokens">
|
||||||
|
<MenuItem>Tokens</MenuItem>
|
||||||
|
</LinkContainer>
|
||||||
|
}
|
||||||
<LinkContainer to="/users/logout">
|
<LinkContainer to="/users/logout">
|
||||||
<MenuItem>Logout</MenuItem>
|
<MenuItem>Logout</MenuItem>
|
||||||
</LinkContainer>
|
</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 jwtDecode from "jwt-decode"
|
||||||
import Cookies from "universal-cookie"
|
import Cookies from "universal-cookie"
|
||||||
@ -11,6 +11,7 @@ const defaultState = Map({
|
|||||||
isLogged: false,
|
isLogged: false,
|
||||||
polochonToken: "",
|
polochonToken: "",
|
||||||
polochonUrl: "",
|
polochonUrl: "",
|
||||||
|
tokens: List(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
@ -27,6 +28,9 @@ const handlers = {
|
|||||||
polochonToken: action.payload.response.data.token,
|
polochonToken: action.payload.response.data.token,
|
||||||
polochonUrl: action.payload.response.data.url,
|
polochonUrl: action.payload.response.data.url,
|
||||||
})),
|
})),
|
||||||
|
"GET_USER_TOKENS_FULFILLED": (state, action) => state.set(
|
||||||
|
"tokens", fromJS(action.payload.response.data)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
function logoutUser() {
|
function logoutUser() {
|
||||||
|
@ -3,6 +3,7 @@ import ShowList from "./components/shows/list"
|
|||||||
import ShowDetails from "./components/shows/details"
|
import ShowDetails from "./components/shows/details"
|
||||||
import UserLoginForm from "./components/users/login"
|
import UserLoginForm from "./components/users/login"
|
||||||
import UserEdit from "./components/users/edit"
|
import UserEdit from "./components/users/edit"
|
||||||
|
import UserTokens from "./components/users/tokens"
|
||||||
import UserActivation from "./components/users/activation"
|
import UserActivation from "./components/users/activation"
|
||||||
import UserSignUp from "./components/users/signup"
|
import UserSignUp from "./components/users/signup"
|
||||||
import TorrentList from "./components/torrents/list"
|
import TorrentList from "./components/torrents/list"
|
||||||
@ -10,7 +11,7 @@ import TorrentSearch from "./components/torrents/search"
|
|||||||
import AdminPanel from "./components/admins/panel"
|
import AdminPanel from "./components/admins/panel"
|
||||||
|
|
||||||
import { fetchTorrents, searchTorrents } from "./actions/torrents"
|
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 { fetchMovies, getMovieExploreOptions } from "./actions/movies"
|
||||||
import { fetchShows, fetchShowDetails, getShowExploreOptions } from "./actions/shows"
|
import { fetchShows, fetchShowDetails, getShowExploreOptions } from "./actions/shows"
|
||||||
import { getUsers, getStats } from "./actions/admins"
|
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",
|
path: "/users/activation",
|
||||||
component: UserActivation,
|
component: UserActivation,
|
||||||
|
@ -53,6 +53,7 @@ body {
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spaced-icons,
|
||||||
.episode-buttons {
|
.episode-buttons {
|
||||||
div, span {
|
div, span {
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@ -103,3 +104,11 @@ table.torrent-search-result {
|
|||||||
table.table-align-middle > tbody > tr > td {
|
table.table-align-middle > tbody > tr > td {
|
||||||
vertical-align: middle;
|
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",
|
"immutable": "^3.8.1",
|
||||||
"jquery": "^2.2.4",
|
"jquery": "^2.2.4",
|
||||||
"jwt-decode": "^2.1.0",
|
"jwt-decode": "^2.1.0",
|
||||||
|
"moment": "^2.20.1",
|
||||||
"react": "^16.2.0",
|
"react": "^16.2.0",
|
||||||
"react-bootstrap": "^0.32.1",
|
"react-bootstrap": "^0.32.1",
|
||||||
"react-bootstrap-sweetalert": "^4.2.3",
|
"react-bootstrap-sweetalert": "^4.2.3",
|
||||||
@ -31,6 +32,7 @@
|
|||||||
"redux": "^3.7.2",
|
"redux": "^3.7.2",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.2.0",
|
||||||
|
"ua-parser-js": "^0.7.17",
|
||||||
"universal-cookie": "^2.1.2",
|
"universal-cookie": "^2.1.2",
|
||||||
"webpack": "^3.11.0"
|
"webpack": "^3.11.0"
|
||||||
},
|
},
|
||||||
|
@ -2975,6 +2975,10 @@ minimist@^1.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimist "0.0.8"
|
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:
|
ms@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||||
@ -4399,7 +4403,7 @@ typedarray@^0.0.6:
|
|||||||
version "0.0.6"
|
version "0.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
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"
|
version "0.7.17"
|
||||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
|
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