User authentication and per-user data (#31)
Add basic user registration and login support. Each user has their own private music library. Reviewed-on: #31pull/34/head
parent
87af6e18a4
commit
f1a5597598
52 changed files with 1883 additions and 2141 deletions
@ -0,0 +1,131 @@ |
||||
import React, { useReducer } from 'react'; |
||||
import { WindowState } from "../Windows"; |
||||
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core"; |
||||
import { useHistory, useLocation } from 'react-router'; |
||||
import { useAuth, Auth } from '../../../lib/useAuth'; |
||||
import Alert from '@material-ui/lab/Alert'; |
||||
|
||||
export enum LoginStatus { |
||||
NoneSubmitted = 0, |
||||
Unsuccessful, |
||||
// Note: no "successful" status because that would lead to a redirect.
|
||||
} |
||||
|
||||
export interface LoginWindowState extends WindowState { |
||||
email: string, |
||||
password: string, |
||||
status: LoginStatus, |
||||
} |
||||
export enum LoginWindowStateActions { |
||||
SetEmail = "SetEmail", |
||||
SetPassword = "SetPassword", |
||||
SetStatus = "SetStatus", |
||||
} |
||||
export function LoginWindowReducer(state: LoginWindowState, action: any) { |
||||
switch (action.type) { |
||||
case LoginWindowStateActions.SetEmail: |
||||
return { ...state, email: action.value } |
||||
case LoginWindowStateActions.SetPassword: |
||||
return { ...state, password: action.value } |
||||
case LoginWindowStateActions.SetStatus: |
||||
return { ...state, status: action.value } |
||||
default: |
||||
throw new Error("Unimplemented LoginWindow state update.") |
||||
} |
||||
} |
||||
|
||||
export default function LoginWindow(props: {}) { |
||||
const [state, dispatch] = useReducer(LoginWindowReducer, { |
||||
email: "", |
||||
password: "", |
||||
status: LoginStatus.NoneSubmitted, |
||||
}); |
||||
|
||||
return <LoginWindowControlled state={state} dispatch={dispatch} /> |
||||
} |
||||
|
||||
export function LoginWindowControlled(props: { |
||||
state: LoginWindowState, |
||||
dispatch: (action: any) => void, |
||||
}) { |
||||
let history: any = useHistory(); |
||||
let location: any = useLocation(); |
||||
let auth: Auth = useAuth(); |
||||
let { from } = location.state || { from: { pathname: "/" } }; |
||||
|
||||
const onSubmit = (event: any) => { |
||||
event.preventDefault(); |
||||
auth.signin(props.state.email, props.state.password) |
||||
.then(() => { |
||||
history.replace(from); |
||||
}).catch((e: any) => { |
||||
props.dispatch({ |
||||
type: LoginWindowStateActions.SetStatus, |
||||
value: LoginStatus.Unsuccessful, |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="500px" |
||||
> |
||||
<Paper> |
||||
<Box p={3}> |
||||
<Typography variant="h5">Sign in</Typography> |
||||
<form noValidate onSubmit={onSubmit}> |
||||
<TextField |
||||
variant="outlined" |
||||
margin="normal" |
||||
required |
||||
fullWidth |
||||
id="email" |
||||
label="Email" |
||||
name="email" |
||||
autoFocus |
||||
onInput={(e: any) => props.dispatch({ |
||||
type: LoginWindowStateActions.SetEmail, |
||||
value: e.target.value |
||||
})} |
||||
/> |
||||
<TextField |
||||
variant="outlined" |
||||
margin="normal" |
||||
required |
||||
fullWidth |
||||
id="password" |
||||
label="Password" |
||||
name="password" |
||||
type="password" |
||||
onInput={(e: any) => props.dispatch({ |
||||
type: LoginWindowStateActions.SetPassword, |
||||
value: e.target.value |
||||
})} |
||||
/> |
||||
{props.state.status === LoginStatus.Unsuccessful && <Alert severity="error"> |
||||
Login failed - Please check your credentials. |
||||
</Alert> |
||||
} |
||||
<Button |
||||
type="submit" |
||||
fullWidth |
||||
variant="outlined" |
||||
color="primary" |
||||
>Sign in</Button> |
||||
<Box display="flex" alignItems="center" mt={2}> |
||||
<Typography>Need an account?</Typography> |
||||
<Box flexGrow={1} ml={2}><Button |
||||
onClick={() => history.replace("/register")} |
||||
fullWidth |
||||
variant="outlined" |
||||
color="primary" |
||||
>Sign up</Button></Box> |
||||
</Box> |
||||
</form> |
||||
</Box> |
||||
</Paper> |
||||
</Box> |
||||
</Box> |
||||
} |
@ -0,0 +1,139 @@ |
||||
import React, { useReducer } from 'react'; |
||||
import { WindowState } from "../Windows"; |
||||
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core"; |
||||
import { useHistory } from 'react-router'; |
||||
import { useAuth, Auth } from '../../../lib/useAuth'; |
||||
import Alert from '@material-ui/lab/Alert'; |
||||
import { Link } from 'react-router-dom'; |
||||
|
||||
export enum RegistrationStatus { |
||||
NoneSubmitted = 0, |
||||
Successful, |
||||
Unsuccessful, |
||||
} |
||||
|
||||
export interface RegisterWindowState extends WindowState { |
||||
email: string, |
||||
password: string, |
||||
status: RegistrationStatus, |
||||
} |
||||
export enum RegisterWindowStateActions { |
||||
SetEmail = "SetEmail", |
||||
SetPassword = "SetPassword", |
||||
SetStatus = "SetStatus", |
||||
} |
||||
export function RegisterWindowReducer(state: RegisterWindowState, action: any) { |
||||
switch (action.type) { |
||||
case RegisterWindowStateActions.SetEmail: |
||||
return { ...state, email: action.value } |
||||
case RegisterWindowStateActions.SetPassword: |
||||
return { ...state, password: action.value } |
||||
case RegisterWindowStateActions.SetStatus: |
||||
return { ...state, status: action.value } |
||||
default: |
||||
throw new Error("Unimplemented RegisterWindow state update.") |
||||
} |
||||
} |
||||
|
||||
export default function RegisterWindow(props: {}) { |
||||
const [state, dispatch] = useReducer(RegisterWindowReducer, { |
||||
email: "", |
||||
password: "", |
||||
status: RegistrationStatus.NoneSubmitted, |
||||
}); |
||||
|
||||
return <RegisterWindowControlled state={state} dispatch={dispatch} /> |
||||
} |
||||
|
||||
export function RegisterWindowControlled(props: { |
||||
state: RegisterWindowState, |
||||
dispatch: (action: any) => void, |
||||
}) { |
||||
let history: any = useHistory(); |
||||
let auth: Auth = useAuth(); |
||||
|
||||
const onSubmit = (event: any) => { |
||||
event.preventDefault(); |
||||
auth.signup(props.state.email, props.state.password) |
||||
.then(() => { |
||||
console.log("succes!") |
||||
props.dispatch({ |
||||
type: RegisterWindowStateActions.SetStatus, |
||||
value: RegistrationStatus.Successful, |
||||
}) |
||||
}).catch((e: any) => { |
||||
console.log("Fail!") |
||||
props.dispatch({ |
||||
type: RegisterWindowStateActions.SetStatus, |
||||
value: RegistrationStatus.Unsuccessful, |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="500px" |
||||
> |
||||
<Paper> |
||||
<Box p={3}> |
||||
<Typography variant="h5">Sign up</Typography> |
||||
<form noValidate onSubmit={onSubmit}> |
||||
<TextField |
||||
variant="outlined" |
||||
margin="normal" |
||||
required |
||||
fullWidth |
||||
id="email" |
||||
label="Email" |
||||
name="email" |
||||
autoFocus |
||||
onInput={(e: any) => props.dispatch({ |
||||
type: RegisterWindowStateActions.SetEmail, |
||||
value: e.target.value |
||||
})} |
||||
/> |
||||
<TextField |
||||
variant="outlined" |
||||
margin="normal" |
||||
required |
||||
fullWidth |
||||
id="password" |
||||
label="Password" |
||||
name="password" |
||||
type="password" |
||||
onInput={(e: any) => props.dispatch({ |
||||
type: RegisterWindowStateActions.SetPassword, |
||||
value: e.target.value |
||||
})} |
||||
/> |
||||
{props.state.status === RegistrationStatus.Successful && <Alert severity="success"> |
||||
Registration successful! Please {<Link to="/login">sign in</Link>} to continue. |
||||
</Alert> |
||||
} |
||||
{props.state.status === RegistrationStatus.Unsuccessful && <Alert severity="error"> |
||||
Registration failed - please check your inputs and try again. |
||||
</Alert> |
||||
} |
||||
{props.state.status !== RegistrationStatus.Successful && <Button |
||||
type="submit" |
||||
fullWidth |
||||
variant="outlined" |
||||
color="primary" |
||||
>Sign up</Button>} |
||||
<Box display="flex" alignItems="center" mt={2}> |
||||
<Typography>Already have an account?</Typography> |
||||
<Box flexGrow={1} ml={2}><Button |
||||
onClick={() => history.replace("/login")} |
||||
fullWidth |
||||
variant="outlined" |
||||
color="primary" |
||||
>Sign in</Button></Box> |
||||
</Box> |
||||
</form> |
||||
</Box> |
||||
</Paper> |
||||
</Box> |
||||
</Box> |
||||
} |
@ -0,0 +1,102 @@ |
||||
// Note: Based on https://usehooks.com/useAuth/
|
||||
|
||||
|
||||
import React, { useState, useContext, createContext, ReactFragment } from "react"; |
||||
import PersonIcon from '@material-ui/icons/Person'; |
||||
import * as serverApi from '../api'; |
||||
|
||||
export interface AuthUser { |
||||
id: number, |
||||
email: string, |
||||
icon: ReactFragment, |
||||
} |
||||
|
||||
export interface Auth { |
||||
user: AuthUser | null, |
||||
signout: () => void, |
||||
signin: (email: string, password: string) => Promise<AuthUser>, |
||||
signup: (email: string, password: string) => Promise<void>, |
||||
}; |
||||
|
||||
const authContext = createContext<Auth>({ |
||||
user: null, |
||||
signout: () => { }, |
||||
signin: (email: string, password: string) => { |
||||
throw new Error("Auth object not initialized."); |
||||
}, |
||||
signup: (email: string, password: string) => { |
||||
throw new Error("Auth object not initialized."); |
||||
}, |
||||
}); |
||||
|
||||
export function ProvideAuth(props: { children: any }) { |
||||
const auth = useProvideAuth(); |
||||
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>; |
||||
} |
||||
|
||||
export const useAuth = () => { |
||||
return useContext(authContext); |
||||
}; |
||||
|
||||
function useProvideAuth() { |
||||
const [user, setUser] = useState<AuthUser | null>(null); |
||||
|
||||
// TODO: password maybe shouldn't be encoded into the URL.
|
||||
const signin = (email: string, password: string) => { |
||||
return (async () => { |
||||
const urlBase = (process.env.REACT_APP_BACKEND || "") + serverApi.LoginEndpoint; |
||||
const url = `${urlBase}?username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`; |
||||
|
||||
const response = await fetch(url, { method: "POST" }); |
||||
const json = await response.json(); |
||||
if (!("userId" in json)) { |
||||
throw new Error("No UserID received from login."); |
||||
} |
||||
|
||||
const user = { |
||||
id: json.userId, |
||||
email: email, |
||||
icon: <PersonIcon />, |
||||
} |
||||
setUser(user); |
||||
return user; |
||||
})(); |
||||
}; |
||||
|
||||
const signup = (email: string, password: string) => { |
||||
return (async () => { |
||||
const requestOpts = { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ |
||||
email: email, |
||||
password: password, |
||||
}) |
||||
}; |
||||
|
||||
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.RegisterUserEndpoint, requestOpts) |
||||
if (!response.ok) { |
||||
throw new Error("Failed to register user.") |
||||
} |
||||
})(); |
||||
}; |
||||
|
||||
const signout = () => { |
||||
return (async () => { |
||||
const url = (process.env.REACT_APP_BACKEND || "") + serverApi.LogoutEndpoint; |
||||
const response = await fetch(url, { method: "POST" }); |
||||
if (!response.ok) { |
||||
throw new Error("Failed to log out."); |
||||
} |
||||
setUser(null); |
||||
})(); |
||||
}; |
||||
|
||||
// Return the user object and auth methods
|
||||
return { |
||||
user, |
||||
signin, |
||||
signup, |
||||
signout, |
||||
}; |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,49 @@ |
||||
import * as api from '../../client/src/api'; |
||||
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; |
||||
import Knex from 'knex'; |
||||
|
||||
import { sha512 } from 'js-sha512'; |
||||
|
||||
export const RegisterUserEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { |
||||
if (!api.checkRegisterUserRequest(req)) { |
||||
const e: EndpointError = { |
||||
internalMessage: 'Invalid RegisterUser request: ' + JSON.stringify(req.body), |
||||
httpStatus: 400 |
||||
}; |
||||
throw e; |
||||
} |
||||
const reqObject: api.RegisterUserRequest = req.body; |
||||
|
||||
console.log("Register User: ", reqObject); |
||||
|
||||
await knex.transaction(async (trx) => { |
||||
try { |
||||
// check if the user already exists
|
||||
const user = (await trx |
||||
.select('id') |
||||
.from('users') |
||||
.where({ email: reqObject.email }))[0]; |
||||
if(user) { |
||||
res.status(400).send(); |
||||
return; |
||||
} |
||||
|
||||
// Create the new user.
|
||||
const passwordHash = sha512(reqObject.password); |
||||
const userId = (await trx('users') |
||||
.insert({ |
||||
email: reqObject.email, |
||||
passwordHash: passwordHash, |
||||
}) |
||||
.returning('id') // Needed for Postgres
|
||||
)[0]; |
||||
|
||||
// Respond to the request.
|
||||
res.status(200).send(); |
||||
|
||||
} catch (e) { |
||||
catchUnhandledErrors(e); |
||||
trx.rollback(); |
||||
} |
||||
}) |
||||
} |
@ -0,0 +1,73 @@ |
||||
import * as Knex from "knex"; |
||||
import { sha512 } from "js-sha512"; |
||||
|
||||
|
||||
export async function up(knex: Knex): Promise<void> { |
||||
// Users table.
|
||||
await knex.schema.createTable( |
||||
'users', |
||||
(table: any) => { |
||||
table.increments('id'); |
||||
table.string('email'); |
||||
table.string('passwordHash') |
||||
} |
||||
) |
||||
|
||||
// Add user column to other object tables.
|
||||
await knex.schema.alterTable( |
||||
'songs', |
||||
(table: any) => { |
||||
table.integer('user').unsigned().notNullable().defaultTo(1); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'albums', |
||||
(table: any) => { |
||||
table.integer('user').unsigned().notNullable().defaultTo(1); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'tags', |
||||
(table: any) => { |
||||
table.integer('user').unsigned().notNullable().defaultTo(1); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'artists', |
||||
(table: any) => { |
||||
table.integer('user').unsigned().notNullable().defaultTo(1); |
||||
} |
||||
) |
||||
} |
||||
|
||||
|
||||
export async function down(knex: Knex): Promise<void> { |
||||
await knex.schema.dropTable('users'); |
||||
|
||||
// Remove the user column
|
||||
await knex.schema.alterTable( |
||||
'songs', |
||||
(table: any) => { |
||||
table.dropColumn('user'); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'albums', |
||||
(table: any) => { |
||||
table.dropColumn('user'); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'tags', |
||||
(table: any) => { |
||||
table.dropColumn('user'); |
||||
} |
||||
) |
||||
await knex.schema.alterTable( |
||||
'artists', |
||||
(table: any) => { |
||||
table.dropColumn('user'); |
||||
} |
||||
) |
||||
} |
||||
|
@ -0,0 +1,145 @@ |
||||
const chai = require('chai'); |
||||
const chaiHttp = require('chai-http'); |
||||
const express = require('express'); |
||||
import { SetupApp } from '../../../app'; |
||||
import * as helpers from './helpers'; |
||||
|
||||
async function init() { |
||||
chai.use(chaiHttp); |
||||
const app = express(); |
||||
const knex = await helpers.initTestDB(); |
||||
|
||||
SetupApp(app, knex, ''); |
||||
|
||||
// Login as a test user.
|
||||
var agent = chai.request.agent(app); |
||||
return agent; |
||||
} |
||||
|
||||
describe('Auth registration password and email constraints', () => { |
||||
it('are enforced', async done => { |
||||
let req = await init(); |
||||
try { |
||||
await helpers.createUser(req, "someone", "password1A!", 400); //no valid email
|
||||
await helpers.createUser(req, "someone@email.com", "password1A", 400); //no special char
|
||||
await helpers.createUser(req, "someone@email.com", "password1!", 400); //no capital letter
|
||||
await helpers.createUser(req, "someone@email.com", "passwordA!", 400); //no number
|
||||
await helpers.createUser(req, "someone@email.com", "Ϭassword1A!", 400); //non-ASCII in password
|
||||
await helpers.createUser(req, "Ϭomeone@email.com", "password1A!", 400); //non-ASCII in email
|
||||
await helpers.createUser(req, "someone@email.com", "pass1A!", 400); //password too short
|
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 200); |
||||
} finally { |
||||
req.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('Attempting to register an already registered user', () => { |
||||
it('should fail', async done => { |
||||
let req = await init(); |
||||
try { |
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 400); |
||||
} finally { |
||||
req.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('Auth login access for users', () => { |
||||
it('is correctly enforced', async done => { |
||||
let req = await init(); |
||||
try { |
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.createUser(req, "someoneelse@other.com", "password2B!", 200); |
||||
await helpers.login(req, "someone@email.com", "password2B!", 401); |
||||
await helpers.login(req, "someoneelse@other.com", "password1A!", 401); |
||||
await helpers.login(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.login(req, "someoneelse@other.com", "password2B!", 200); |
||||
} finally { |
||||
req.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('Auth access to objects', () => { |
||||
it('is only possible when logged in', async done => { |
||||
let req = await init(); |
||||
try { |
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.login(req, "someone@email.com", "password1A!", 200); |
||||
|
||||
await helpers.createTag(req, { name: "Tag1" }, 200, { id: 1 }); |
||||
await helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1} ); |
||||
await helpers.createAlbum(req, { name: "Album1" }, 200, { id: 1 }); |
||||
await helpers.createSong(req, { title: "Song1" }, 200, { id: 1 }); |
||||
|
||||
await helpers.checkTag(req, 1, 200); |
||||
await helpers.checkAlbum(req, 1, 200); |
||||
await helpers.checkArtist(req, 1, 200); |
||||
await helpers.checkSong(req, 1, 200); |
||||
|
||||
await helpers.logout(req, 200); |
||||
|
||||
await helpers.checkTag(req, 1, 401); |
||||
await helpers.checkAlbum(req, 1, 401); |
||||
await helpers.checkArtist(req, 1, 401); |
||||
await helpers.checkSong(req, 1, 401); |
||||
} finally { |
||||
req.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('Auth access to user objects', () => { |
||||
it('is restricted to each user', async done => { |
||||
let req = await init(); |
||||
try { |
||||
await helpers.createUser(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.createUser(req, "someoneelse@other.com", "password2B!", 200); |
||||
|
||||
await helpers.login(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.createTag(req, { name: "Tag1" }, 200, { id: 1 }); |
||||
await helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1} ); |
||||
await helpers.createAlbum(req, { name: "Album1" }, 200, { id: 1 }); |
||||
await helpers.createSong(req, { title: "Song1" }, 200, { id: 1 }); |
||||
await helpers.logout(req, 200); |
||||
|
||||
await helpers.login(req, "someoneelse@other.com", "password2B!", 200); |
||||
await helpers.createTag(req, { name: "Tag2" }, 200, { id: 2 }); |
||||
await helpers.createArtist(req, { name: "Artist2" }, 200, { id: 2 } ); |
||||
await helpers.createAlbum(req, { name: "Album2" }, 200, { id: 2 }); |
||||
await helpers.createSong(req, { title: "Song2" }, 200, { id: 2 }); |
||||
await helpers.logout(req, 200); |
||||
|
||||
await helpers.login(req, "someone@email.com", "password1A!", 200); |
||||
await helpers.checkTag(req, 2, 404); |
||||
await helpers.checkAlbum(req, 2, 404); |
||||
await helpers.checkArtist(req, 2, 404); |
||||
await helpers.checkSong(req, 2, 404); |
||||
await helpers.checkTag(req, 1, 200); |
||||
await helpers.checkAlbum(req, 1, 200); |
||||
await helpers.checkArtist(req, 1, 200); |
||||
await helpers.checkSong(req, 1, 200); |
||||
await helpers.logout(req, 200); |
||||
|
||||
await helpers.login(req, "someoneelse@other.com", "password2B!", 200); |
||||
await helpers.checkTag(req, 1, 404); |
||||
await helpers.checkAlbum(req, 1, 404); |
||||
await helpers.checkArtist(req, 1, 404); |
||||
await helpers.checkSong(req, 1, 404); |
||||
await helpers.checkTag(req, 2, 200); |
||||
await helpers.checkAlbum(req, 2, 200); |
||||
await helpers.checkArtist(req, 2, 200); |
||||
await helpers.checkSong(req, 2, 200); |
||||
await helpers.logout(req, 200); |
||||
} finally { |
||||
req.close(); |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
Loading…
Reference in new issue