From 58a7879ba83b59370da74300bc67f59ec660cd2c Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 25 Jan 2020 17:10:16 -0500 Subject: [PATCH] feat: first time admin user creation through the ui --- README.md | 11 +- consts/consts.go | 3 +- persistence/user_repository.go | 2 +- server/app/app.go | 3 +- server/app/auth.go | 197 +++++++++++++++++++++++---------- server/initial_setup.go | 34 ------ ui/src/authProvider.js | 24 ++-- ui/src/dataProvider.js | 1 + ui/src/layout/Login.js | 173 +++++++++++++++++++++++------ 9 files changed, 301 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 0e9d50615..c9dd9bb98 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,10 @@ This will generate the `navidrome` binary in the project's root folder. Start th ``` The server should start listening for requests on the default port __4533__ -### First time password -The first time you start the app it will create a new user "admin" with a random password. -Check the logs for a line like this: -``` -Creating initial user. Please change the password! password=XXXXXX user=admin -``` +### Running for the first time -You can change this password using the UI. Just browse to http://localhost:4533/app#/user -and login with this temporary password. +After starting Navidrome for the first time, go to http://localhost:4533. It will ask you to create your first admin +user. ## Screenshots diff --git a/consts/consts.go b/consts/consts.go index ce14187b4..6cf3e37f9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -7,11 +7,10 @@ const ( InitialSetupFlagKey = "InitialSetup" JWTSecretKey = "JWTSecret" - JWTIssuer = "Navidrome" + JWTIssuer = "ND" JWTTokenExpiration = 30 * time.Minute InitialUserName = "admin" - InitialName = "Admin" UIAssetsLocalPath = "ui/build" ) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index ac768cb1a..9187dccc9 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -63,7 +63,7 @@ func (r *userRepository) Put(u *model.User) error { func (r *userRepository) FindByUsername(username string) (*model.User, error) { tu := user{} - err := r.ormer.QueryTable(user{}).Filter("user_name", username).One(&tu) + err := r.ormer.QueryTable(user{}).Filter("user_name__iexact", username).One(&tu) if err == orm.ErrNoRows { return nil, model.ErrNotFound } diff --git a/server/app/app.go b/server/app/app.go index ca4504aea..fe9f49114 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -43,11 +43,12 @@ func (app *Router) routes() http.Handler { r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) }) r.Post("/login", Login(app.ds)) + r.Post("/createAdmin", CreateAdmin(app.ds)) r.Route("/api", func(r chi.Router) { if !conf.Server.DevDisableAuthentication { r.Use(jwtauth.Verifier(TokenAuth)) - r.Use(Authenticator) + r.Use(Authenticator(app.ds)) } app.R(r, "/user", model.User{}) app.R(r, "/song", model.MediaFile{}) diff --git a/server/app/auth.go b/server/app/auth.go index dfb85cc13..c70a93c24 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -3,64 +3,128 @@ package app import ( "context" "encoding/json" + "errors" "net/http" "strings" "sync" "time" "github.com/deluan/navidrome/consts" + "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" "github.com/deluan/rest" "github.com/dgrijalva/jwt-go" "github.com/go-chi/jwtauth" - log "github.com/sirupsen/logrus" + "github.com/google/uuid" ) var ( - once sync.Once - jwtSecret []byte - TokenAuth *jwtauth.JWTAuth + once sync.Once + jwtSecret []byte + TokenAuth *jwtauth.JWTAuth + ErrFirstTime = errors.New("no users created") ) func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { initTokenAuth(ds) 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) + username, password, err := getCredentialsFromBody(r) 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") + log.Error(r, "Parsing request body", err) + rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) 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, - "name": strings.Title(user.Name), - "username": username, - }) + handleLogin(ds, username, password, w, r) } } +func handleLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { + 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.Warn(r, "Unsuccessful login", "username", username, "request", 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") + return + } + rest.RespondWithJSON(w, http.StatusOK, + map[string]interface{}{ + "message": "User '" + username + "' authenticated successfully", + "token": tokenString, + "name": user.Name, + "username": username, + }) +} + +func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { + data := make(map[string]string) + decoder := json.NewDecoder(r.Body) + if err = decoder.Decode(&data); err != nil { + log.Error(r, "parsing request body", err) + err = errors.New("Invalid request payload") + return + } + username = data["username"] + password = data["password"] + return username, password, nil +} + +func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { + initTokenAuth(ds) + + return func(w http.ResponseWriter, r *http.Request) { + username, password, err := getCredentialsFromBody(r) + if err != nil { + log.Error(r, "parsing request body", err) + rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + c, err := ds.User().CountAll() + if err != nil { + rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + if c > 0 { + rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin") + return + } + err = createDefaultUser(ds, username, password) + if err != nil { + rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + handleLogin(ds, username, password, w, r) + } +} + +func createDefaultUser(ds model.DataStore, username, password string) error { + id, _ := uuid.NewRandom() + log.Warn("Creating initial user", "user", consts.InitialUserName) + initialUser := model.User{ + ID: id.String(), + UserName: username, + Name: strings.Title(username), + Email: "", + Password: password, + IsAdmin: true, + } + err := ds.User().Put(&initialUser) + if err != nil { + log.Error("Could not create initial user", "user", initialUser, err) + } + return nil +} + func initTokenAuth(ds model.DataStore) { once.Do(func() { secret, err := ds.Property().DefaultGet(consts.JWTSecretKey, "not so secret") @@ -117,31 +181,50 @@ func userFrom(claims jwt.MapClaims) *model.User { 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()) +func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) { + token, claims, err := jwtauth.FromContext(ctx) - if err != nil { - rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") - return - } + valid := err == nil && token != nil && token.Valid + valid = valid && claims["sub"] != nil + if valid { + return token, nil + } - 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)) - }) + c, err := ds.User().CountAll() + firstTime := c == 0 && err == nil + if firstTime { + return nil, ErrFirstTime + } + return nil, errors.New("invalid authentication") +} + +func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { + initTokenAuth(ds) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := getToken(ds, r.Context()) + if err == ErrFirstTime { + rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()}) + return + } + if err != nil { + 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.Error(r, "signing new token", err) + rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + w.Header().Set("Authorization", newTokenString) + next.ServeHTTP(w, r.WithContext(newCtx)) + }) + } } diff --git a/server/initial_setup.go b/server/initial_setup.go index 043f8eee9..d1b47aa0a 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -1,10 +1,8 @@ package server import ( - "fmt" "time" - "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/consts" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" @@ -18,9 +16,6 @@ func initialSetup(ds model.DataStore) { return nil } log.Warn("Running initial setup") - if err = createDefaultUser(ds); err != nil { - return err - } if err = createJWTSecret(ds); err != nil { return err } @@ -43,32 +38,3 @@ func createJWTSecret(ds model.DataStore) error { } return err } - -func createDefaultUser(ds model.DataStore) error { - c, err := ds.User().CountAll() - if err != nil { - panic(fmt.Sprintf("Could not access User table: %s", err)) - } - if c == 0 { - id, _ := uuid.NewRandom() - random, _ := uuid.NewRandom() - initialPassword := random.String() - if conf.Server.DevInitialPassword != "" { - initialPassword = conf.Server.DevInitialPassword - } - log.Warn("Creating initial user. Please change the password!", "user", consts.InitialUserName, "password", initialPassword) - initialUser := model.User{ - ID: id.String(), - UserName: consts.InitialUserName, - Name: consts.InitialName, - Email: "", - Password: initialPassword, - IsAdmin: true, - } - err := ds.User().Put(&initialUser) - if err != nil { - log.Error("Could not create initial user", "user", initialUser, err) - } - } - return err -} diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 0a9c84ee5..5492aa7af 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -2,7 +2,11 @@ import jwtDecode from 'jwt-decode' const authProvider = { login: ({ username, password }) => { - const request = new Request('/app/login', { + let url = '/app/login' + if (localStorage.getItem('initialAccountCreation')) { + url = '/app/createAdmin' + } + const request = new Request(url, { method: 'POST', body: JSON.stringify({ username, password }), headers: new Headers({ 'Content-Type': 'application/json' }) @@ -17,6 +21,7 @@ const authProvider = { .then((response) => { // Validate token jwtDecode(response.token) + localStorage.removeItem('initialAccountCreation') localStorage.setItem('token', response.token) localStorage.setItem('name', response.name) localStorage.setItem('username', response.username) @@ -39,19 +44,14 @@ const authProvider = { 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() - } - }, + checkAuth: () => + localStorage.getItem('token') ? Promise.resolve() : Promise.reject(), checkError: (error) => { - const { status } = error - // TODO Remove 403? + const { status, message } = error + if (message === 'no users created') { + localStorage.setItem('initialAccountCreation', 'true') + } if (status === 401 || status === 403) { removeItems() return Promise.reject() diff --git a/ui/src/dataProvider.js b/ui/src/dataProvider.js index d50225ea4..4b0280a04 100644 --- a/ui/src/dataProvider.js +++ b/ui/src/dataProvider.js @@ -13,6 +13,7 @@ const httpClient = (url, options = {}) => { const token = response.headers.get('authorization') if (token) { localStorage.setItem('token', token) + localStorage.removeItem('initialAccountCreation') } return response }) diff --git a/ui/src/layout/Login.js b/ui/src/layout/Login.js index 75f6a35bf..67fe5a8bb 100644 --- a/ui/src/layout/Login.js +++ b/ui/src/layout/Login.js @@ -71,40 +71,9 @@ const renderInput = ({ /> ) -const Login = ({ location }) => { - const [loading, setLoading] = useState(false) +const FormLogin = ({ loading, handleSubmit, validate }) => { const translate = useTranslate() const classes = useStyles() - const notify = useNotify() - const login = useLogin() - - const handleSubmit = (auth) => { - setLoading(true) - login(auth, location.state ? location.state.nextPathname : '/').catch( - (error) => { - setLoading(false) - notify( - typeof error === 'string' - ? error - : typeof error === 'undefined' || !error.message - ? 'ra.auth.sign_in_error' - : error.message, - 'warning' - ) - } - ) - } - - const validate = (values) => { - const errors = {} - if (!values.username) { - errors.username = translate('ra.validation.required') - } - if (!values.password) { - errors.password = translate('ra.validation.required') - } - return errors - } return (
{ ) } +const FormSignUp = ({ loading, handleSubmit, validate }) => { + const translate = useTranslate() + const classes = useStyles() + + return ( + ( + +
+ +
+ + + +
+
+ Thanks for installing Navidrome! +
+
+ To start, create an admin user +
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+ +
+
+ )} + /> + ) +} +const Login = ({ location }) => { + const [loading, setLoading] = useState(false) + const translate = useTranslate() + const notify = useNotify() + const login = useLogin() + + const handleSubmit = (auth) => { + setLoading(true) + login(auth, location.state ? location.state.nextPathname : '/').catch( + (error) => { + setLoading(false) + notify( + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? 'ra.auth.sign_in_error' + : error.message, + 'warning' + ) + } + ) + } + + const validateLogin = (values) => { + const errors = {} + if (!values.username) { + errors.username = translate('ra.validation.required') + } + if (!values.password) { + errors.password = translate('ra.validation.required') + } + return errors + } + + const validateSignup = (values) => { + const errors = validateLogin(values) + const regex = /^\w+$/g + if (values.username && !values.username.match(regex)) { + errors.username = translate('Please only use letter and numbers') + } + if (!values.confirmPassword) { + errors.confirmPassword = translate('ra.validation.required') + } + if (values.confirmPassword !== values.password) { + errors.confirmPassword = 'Password does not match' + } + return errors + } + + if (localStorage.getItem('initialAccountCreation') === 'true') { + return ( + + ) + } + return ( + + ) +} + Login.propTypes = { authProvider: PropTypes.func, previousRoute: PropTypes.string