diff --git a/go.mod b/go.mod index 40066a259..81e14e6fc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b9fc00abd..94fd0377e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/model/user.go b/model/user.go index f2e2ab2bc..f59e8195e 100644 --- a/model/user.go +++ b/model/user.go @@ -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 } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 71e736c52..e2d8e1062 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -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{}) diff --git a/server/app/app.go b/server/app/app.go index ea25774c8..49320c08c 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -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) } } diff --git a/server/app/auth.go b/server/app/auth.go new file mode 100644 index 000000000..562d3e787 --- /dev/null +++ b/server/app/auth.go @@ -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) +} diff --git a/ui/package-lock.json b/ui/package-lock.json index de14617d0..c8d24ed64 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 621cc64ad..bc89e38d6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/App.js b/ui/src/App.js index a8c819b97..dd3fd5201 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -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 = () => ( - + ) diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js new file mode 100644 index 000000000..0a9c84ee5 --- /dev/null +++ b/ui/src/authProvider.js @@ -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 diff --git a/ui/src/dataProvider.js b/ui/src/dataProvider.js new file mode 100644 index 000000000..d50225ea4 --- /dev/null +++ b/ui/src/dataProvider.js @@ -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 diff --git a/ui/src/user/UserCreate.js b/ui/src/user/UserCreate.js index 7110457df..6a5dcab26 100644 --- a/ui/src/user/UserCreate.js +++ b/ui/src/user/UserCreate.js @@ -5,13 +5,16 @@ import { TextInput, PasswordInput, required, + email, SimpleForm } from 'react-admin' const UserCreate = (props) => ( + + diff --git a/ui/src/user/UserEdit.js b/ui/src/user/UserEdit.js index 92a24842b..18345dd2c 100644 --- a/ui/src/user/UserEdit.js +++ b/ui/src/user/UserEdit.js @@ -6,6 +6,7 @@ import { PasswordInput, Edit, required, + email, SimpleForm } from 'react-admin' @@ -15,7 +16,9 @@ const UserTitle = ({ record }) => { const UserEdit = (props) => ( } {...props}> + + diff --git a/ui/src/user/UserList.js b/ui/src/user/UserList.js index 7c1c3cae2..3f740523c 100644 --- a/ui/src/user/UserList.js +++ b/ui/src/user/UserList.js @@ -23,7 +23,7 @@ const UserList = (props) => { return ( } > @@ -34,9 +34,8 @@ const UserList = (props) => { /> ) : ( - + -