feat: first time admin user creation through the ui

This commit is contained in:
Deluan 2020-01-25 17:10:16 -05:00
parent b4c95fa8db
commit 58a7879ba8
9 changed files with 301 additions and 147 deletions

View File

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

View File

@ -7,11 +7,10 @@ const (
InitialSetupFlagKey = "InitialSetup"
JWTSecretKey = "JWTSecret"
JWTIssuer = "Navidrome"
JWTIssuer = "ND"
JWTTokenExpiration = 30 * time.Minute
InitialUserName = "admin"
InitialName = "Admin"
UIAssetsLocalPath = "ui/build"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<Form
@ -162,6 +131,146 @@ const Login = ({ location }) => {
)
}
const FormSignUp = ({ loading, handleSubmit, validate }) => {
const translate = useTranslate()
const classes = useStyles()
return (
<Form
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<Avatar className={classes.icon}>
<LockIcon />
</Avatar>
</div>
<div className={classes.systemName}>
Thanks for installing Navidrome!
</div>
<div className={classes.systemName}>
To start, create an admin user
</div>
<div className={classes.form}>
<div className={classes.input}>
<Field
autoFocus
name="username"
component={renderInput}
label={'Admin Username'}
disabled={loading}
/>
</div>
<div className={classes.input}>
<Field
name="password"
component={renderInput}
label={translate('ra.auth.password')}
type="password"
disabled={loading}
/>
</div>
<div className={classes.input}>
<Field
name="confirmPassword"
component={renderInput}
label={'Confirm Password'}
type="password"
disabled={loading}
/>
</div>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate('Create Admin')}
</Button>
</CardActions>
</Card>
<Notification />
</div>
</form>
)}
/>
)
}
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 (
<FormSignUp
handleSubmit={handleSubmit}
validate={validateSignup}
loading={loading}
/>
)
}
return (
<FormLogin
handleSubmit={handleSubmit}
validate={validateLogin}
loading={loading}
/>
)
}
Login.propTypes = {
authProvider: PropTypes.func,
previousRoute: PropTypes.string