Authenticate UI

This commit is contained in:
Deluan 2020-01-20 09:54:29 -05:00
parent 6785d616d0
commit e717d99780
14 changed files with 313 additions and 22 deletions

2
go.mod
View file

@ -8,11 +8,13 @@ require (
github.com/astaxie/beego v1.12.0
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
github.com/go-chi/cors v1.0.0
github.com/go-chi/jwtauth v4.0.3+incompatible
github.com/google/uuid v1.1.1
github.com/google/wire v0.4.0
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629

4
go.sum
View file

@ -24,6 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
@ -39,6 +41,8 @@ github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8q
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=

View file

@ -4,7 +4,9 @@ import "time"
type User struct {
ID string
UserName string
Name string
Email string
Password string
IsAdmin bool
LastLoginAt *time.Time
@ -17,4 +19,6 @@ type UserRepository interface {
CountAll(...QueryOptions) (int64, error)
Get(id string) (*User, error)
Put(*User) error
FindByUsername(username string) (*User, error)
UpdateLastLoginAt(id string) error
}

View file

@ -10,7 +10,9 @@ import (
type user struct {
ID string `json:"id" orm:"pk;column(id)"`
Name string `json:"name" orm:"index"`
UserName string `json:"userName" orm:"index;unique"`
Name string `json:"name"`
Email string `json:"email" orm:"unique"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
LastLoginAt *time.Time `json:"lastLoginAt" orm:"null"`
@ -24,6 +26,12 @@ type userRepository struct {
userResource model.ResourceRepository
}
func NewUserRepository(o orm.Ormer) model.UserRepository {
r := &userRepository{ormer: o}
r.userResource = NewResource(o, model.User{}, new(user))
return r
}
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
if len(qo) > 0 {
return r.userResource.Count(rest.QueryOptions(qo[0]))
@ -41,22 +49,36 @@ func (r *userRepository) Get(id string) (*model.User, error) {
}
func (r *userRepository) Put(u *model.User) error {
tu := user(*u)
c, err := r.CountAll()
if err != nil {
return err
}
if c == 0 {
mappedUser := user(*u)
_, err = r.userResource.Save(&mappedUser)
_, err = r.userResource.Save(&tu)
return err
}
return r.userResource.Update(u, "name", "is_admin", "password")
return r.userResource.Update(&tu, "user_name", "is_admin", "password")
}
func NewUserRepository(o orm.Ormer) model.UserRepository {
r := &userRepository{ormer: o}
r.userResource = NewResource(o, model.User{}, new(user))
return r
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
tu := user{}
err := r.ormer.QueryTable(user{}).Filter("user_name", username).One(&tu)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
if err != nil {
return nil, err
}
u := model.User(tu)
return &u, err
}
func (r *userRepository) UpdateLastLoginAt(id string) error {
now := time.Now()
tu := user{ID: id, LastLoginAt: &now}
_, err := r.ormer.Update(&tu, "last_login_at")
return err
}
var _ = model.User(user{})

View file

@ -12,10 +12,15 @@ import (
"github.com/cloudsonic/sonic-server/server"
"github.com/deluan/rest"
"github.com/go-chi/chi"
"github.com/go-chi/jwtauth"
"github.com/google/uuid"
)
const initialUser = "admin"
var initialUser = model.User{
UserName: "admin",
Name: "Admin",
IsAdmin: true,
}
type Router struct {
ds model.DataStore
@ -43,8 +48,12 @@ func (app *Router) routes() http.Handler {
// Basic unauthenticated ping
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
r.Post("/login", Login(app.ds))
r.Route("/api", func(r chi.Router) {
// Add User resource
r.Use(jwtauth.Verifier(TokenAuth))
r.Use(Authenticator)
R(r, "/user", func(ctx context.Context) rest.Repository {
return app.ds.Resource(model.User{})
})
@ -60,13 +69,10 @@ func (app *Router) createDefaultUser() {
if c == 0 {
id, _ := uuid.NewRandom()
initialPassword, _ := uuid.NewRandom()
log.Warn("Creating initial user. Please change the password!", "user", initialUser, "password", initialPassword)
app.ds.User().Put(&model.User{
ID: id.String(),
Name: initialUser,
Password: initialPassword.String(),
IsAdmin: true,
})
log.Warn("Creating initial user. Please change the password!", "user", initialUser.UserName, "password", initialPassword)
initialUser.ID = id.String()
initialUser.Password = initialPassword.String()
app.ds.User().Put(&initialUser)
}
}

144
server/app/auth.go Normal file
View file

@ -0,0 +1,144 @@
package app
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"time"
"github.com/cloudsonic/sonic-server/model"
"github.com/deluan/rest"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/jwtauth"
log "github.com/sirupsen/logrus"
)
var (
tokenExpiration = 30 * time.Minute
issuer = "CloudSonic"
)
var (
jwtSecret []byte
TokenAuth *jwtauth.JWTAuth
)
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
data := make(map[string]string)
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&data); err != nil {
log.Errorf("parsing request body: %#v", err)
rest.RespondWithError(w, http.StatusUnprocessableEntity, "Invalid request payload")
return
}
username := data["username"]
password := data["password"]
user, err := validateLogin(ds.User(), username, password)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
return
}
if user == nil {
log.Warnf("Unsuccessful login: '%s', request: %v", username, r.Header)
rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
return
}
tokenString, err := createToken(user)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
}
rest.RespondWithJSON(w, http.StatusOK,
map[string]interface{}{
"message": "User '" + username + "' authenticated successfully",
"token": tokenString,
"user": strings.Title(user.UserName),
"username": username,
})
}
}
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
u, err := userRepo.FindByUsername(userName)
if err == model.ErrNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
if u.Password != password {
return nil, nil
}
err = userRepo.UpdateLastLoginAt(u.ID)
if err != nil {
log.Error("Could not update LastLoginAt", "user", userName)
}
return u, nil
}
func createToken(u *model.User) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["iss"] = issuer
claims["sub"] = u.UserName
return touchToken(token)
}
func touchToken(token *jwt.Token) (string, error) {
expireIn := time.Now().Add(tokenExpiration).Unix()
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expireIn
return token.SignedString(jwtSecret)
}
func userFrom(claims jwt.MapClaims) *model.User {
user := &model.User{
UserName: claims["sub"].(string),
}
return user
}
func Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil {
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
if token == nil || !token.Valid {
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
claims := token.Claims.(jwt.MapClaims)
newCtx := context.WithValue(r.Context(), "loggedUser", userFrom(claims))
newTokenString, err := touchToken(token)
if err != nil {
log.Errorf("signing new token: %v", err)
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
w.Header().Set("Authorization", newTokenString)
next.ServeHTTP(w, r.WithContext(newCtx))
})
}
func init() {
// TODO Store jwtSecret in the DB
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "not so secret"
log.Warn("No JWT_SECRET env var found. Please set one.")
}
jwtSecret = []byte(secret)
TokenAuth = jwtauth.New("HS256", jwtSecret, nil)
}

5
ui/package-lock.json generated
View file

@ -8834,6 +8834,11 @@
"object.assign": "^4.1.0"
}
},
"jwt-decode": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz",
"integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk="
},
"killable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",

View file

@ -6,6 +6,7 @@
"@testing-library/jest-dom": "^5.0.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"jwt-decode": "^2.2.0",
"prop-types": "^15.7.2",
"ra-data-json-server": "^3.1.2",
"react": "^16.12.0",

View file

@ -1,13 +1,17 @@
// in src/App.js
import React from 'react'
import { Admin, Resource } from 'react-admin'
import jsonServerProvider from 'ra-data-json-server'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
import { Login } from './layout'
import user from './user'
const dataProvider = jsonServerProvider('/app/api')
const App = () => (
<Admin dataProvider={dataProvider} loginPage={Login}>
<Admin
dataProvider={dataProvider}
authProvider={authProvider}
loginPage={Login}
>
<Resource name="user" {...user} />
</Admin>
)

71
ui/src/authProvider.js Normal file
View file

@ -0,0 +1,71 @@
import jwtDecode from 'jwt-decode'
const authProvider = {
login: ({ username, password }) => {
const request = new Request('/app/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({ 'Content-Type': 'application/json' })
})
return fetch(request)
.then((response) => {
if (response.status < 200 || response.status >= 300) {
throw new Error(response.statusText)
}
return response.json()
})
.then((response) => {
// Validate token
jwtDecode(response.token)
localStorage.setItem('token', response.token)
localStorage.setItem('name', response.name)
localStorage.setItem('username', response.username)
return response
})
.catch((error) => {
if (
error.message === 'Failed to fetch' ||
error.stack === 'TypeError: Failed to fetch'
) {
throw new Error('errors.network_error')
}
throw new Error(error)
})
},
logout: () => {
removeItems()
return Promise.resolve()
},
checkAuth: () => {
try {
const expireTime = jwtDecode(localStorage.getItem('token')).exp * 1000
const now = new Date().getTime()
return now < expireTime ? Promise.resolve() : Promise.reject()
} catch (e) {
return Promise.reject()
}
},
checkError: (error) => {
const { status } = error
// TODO Remove 403?
if (status === 401 || status === 403) {
removeItems()
return Promise.reject()
}
return Promise.resolve()
},
getPermissions: (params) => Promise.resolve()
}
const removeItems = () => {
localStorage.removeItem('token')
localStorage.removeItem('name')
localStorage.removeItem('username')
}
export default authProvider

23
ui/src/dataProvider.js Normal file
View file

@ -0,0 +1,23 @@
import { fetchUtils } from 'react-admin'
import jsonServerProvider from 'ra-data-json-server'
const httpClient = (url, options = {}) => {
if (!options.headers) {
options.headers = new Headers({ Accept: 'application/json' })
}
const token = localStorage.getItem('token')
if (token) {
options.headers.set('Authorization', `Bearer ${token}`)
}
return fetchUtils.fetchJson(url, options).then((response) => {
const token = response.headers.get('authorization')
if (token) {
localStorage.setItem('token', token)
}
return response
})
}
const dataProvider = jsonServerProvider('/app/api', httpClient)
export default dataProvider

View file

@ -5,13 +5,16 @@ import {
TextInput,
PasswordInput,
required,
email,
SimpleForm
} from 'react-admin'
const UserCreate = (props) => (
<Create {...props}>
<SimpleForm redirect="list">
<TextInput source="userName" validate={[required()]} />
<TextInput source="name" validate={[required()]} />
<TextInput source="email" validate={[required(), email()]} />
<PasswordInput source="password" validate={[required()]} />
<BooleanInput source="isAdmin" initialValue={false} />
</SimpleForm>

View file

@ -6,6 +6,7 @@ import {
PasswordInput,
Edit,
required,
email,
SimpleForm
} from 'react-admin'
@ -15,7 +16,9 @@ const UserTitle = ({ record }) => {
const UserEdit = (props) => (
<Edit title={<UserTitle />} {...props}>
<SimpleForm>
<TextInput source="userName" validate={[required()]} />
<TextInput source="name" validate={[required()]} />
<TextInput source="email" validate={[required(), email()]} />
<PasswordInput source="password" validate={[required()]} />
<BooleanInput source="isAdmin" initialValue={false} />
<DateField source="lastLoginAt" />

View file

@ -23,7 +23,7 @@ const UserList = (props) => {
return (
<List
{...props}
sort={{ field: 'name', order: 'ASC' }}
sort={{ field: 'userName', order: 'ASC' }}
exporter={false}
filters={<UserFilter />}
>
@ -34,9 +34,8 @@ const UserList = (props) => {
/>
) : (
<Datagrid rowClick="edit">
<TextField source="name" />
<TextField source="userName" />
<BooleanField source="isAdmin" />
<DateField source="lastLoginAt" locales="pt-BR" />
<DateField source="lastAccessAt" locales="pt-BR" />
<DateField source="updatedAt" locales="pt-BR" />
</Datagrid>