Merge branch 'user-tokens' into 'master'

User tokens

See merge request !94
This commit is contained in:
Lucas 2018-03-14 12:07:37 +00:00
commit 1593af9fde
18 changed files with 554 additions and 148 deletions

View File

@ -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
} }

View File

@ -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,20 +27,21 @@ 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 {
http.Error(w, err.Error(), http.StatusUnauthorized) case nil:
return
}
if user != nil {
m.log.Debugf("setting user %s in the context", user.GetName()) 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") ctxKey := authContextKey("auth.user")
ctx := context.WithValue(r.Context(), ctxKey, user) ctx := context.WithValue(r.Context(), ctxKey, user)
r = r.WithContext(ctx) 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) 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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
View 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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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(),
]
)
}

View File

@ -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>

View 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);

View File

@ -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() {

View File

@ -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,

View File

@ -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;
}

View File

@ -0,0 +1 @@
DROP TABLE tokens;

View 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();

View File

@ -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"
}, },

View File

@ -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"