Added proper login and registration flows.

This commit is contained in:
Andrew Lalis 2023-01-31 11:53:06 +01:00
parent 6b1e20d544
commit c58e29cbd8
22 changed files with 447 additions and 23 deletions

View File

@ -12,3 +12,12 @@ services:
environment: environment:
POSTGRES_USER: gymboard-api-dev POSTGRES_USER: gymboard-api-dev
POSTGRES_PASSWORD: testpass POSTGRES_PASSWORD: testpass
mailhog:
image: mailhog/mailhog
restart: always
expose:
- "8025"
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI

View File

@ -37,6 +37,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>

View File

@ -51,7 +51,9 @@ public class SecurityConfig {
HttpMethod.POST, HttpMethod.POST,
"/gyms/submissions", "/gyms/submissions",
"/gyms/submissions/upload", "/gyms/submissions/upload",
"/auth/token" "/auth/token",
"/auth/register",
"/auth/activate"
).permitAll() ).permitAll()
// Everything else must be authenticated, just to be safe. // Everything else must be authenticated, just to be safe.
.anyRequest().authenticated(); .anyRequest().authenticated();

View File

@ -1,10 +1,9 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; import nl.andrewlalis.gymboard_api.controller.dto.*;
import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse;
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.model.auth.User;
import nl.andrewlalis.gymboard_api.service.auth.TokenService; import nl.andrewlalis.gymboard_api.service.auth.TokenService;
import nl.andrewlalis.gymboard_api.service.auth.UserService;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -15,9 +14,35 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
public class AuthController { public class AuthController {
private final TokenService tokenService; private final TokenService tokenService;
private final UserService userService;
public AuthController(TokenService tokenService) { public AuthController(TokenService tokenService, UserService userService) {
this.tokenService = tokenService; this.tokenService = tokenService;
this.userService = userService;
}
/**
* Endpoint for registering a new user in the system. <strong>This is a
* public endpoint.</strong> If the user is successfully created, an email
* will be sent to them with a link for activating the account.
* @param payload The payload.
* @return The created user.
*/
@PostMapping(path = "/auth/register")
public UserResponse registerNewUser(@RequestBody UserCreationPayload payload) {
return userService.createUser(payload, true);
}
/**
* Endpoint for activating a new user via an activation code. <strong>This
* is a public endpoint.</strong> If the code is recent (within 24 hours)
* and the user exists, then they'll be activated and able to log in.
* @param payload The payload containing the activation code.
* @return The activated user.
*/
@PostMapping(path = "/auth/activate")
public UserResponse activateUser(@RequestBody UserActivationPayload payload) {
return userService.activateUser(payload);
} }
/** /**

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record UserActivationPayload(String code) {}

View File

@ -0,0 +1,12 @@
package nl.andrewlalis.gymboard_api.dao.auth;
import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserActivationCodeRepository extends JpaRepository<UserActivationCode, Long> {
Optional<UserActivationCode> findByCode(String code);
}

View File

@ -144,7 +144,7 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
String[] roleNames = record.get(3).split("\\s*\\|\\s*"); String[] roleNames = record.get(3).split("\\s*\\|\\s*");
UserCreationPayload payload = new UserCreationPayload(email, password, name); UserCreationPayload payload = new UserCreationPayload(email, password, name);
var resp = userService.createUser(payload); var resp = userService.createUser(payload, false);
User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow(); User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow();
for (var roleName : roleNames) { for (var roleName : roleNames) {
if (roleName.isBlank()) continue; if (roleName.isBlank()) continue;

View File

@ -60,6 +60,10 @@ public class User {
return activated; return activated;
} }
public void setActivated(boolean activated) {
this.activated = activated;
}
public String getEmail() { public String getEmail() {
return email; return email;
} }

View File

@ -0,0 +1,46 @@
package nl.andrewlalis.gymboard_api.model.auth;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "auth_user_activation_code")
public class UserActivationCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user;
@Column(nullable = false, unique = true, updatable = false, length = 127)
private String code;
public UserActivationCode() {}
public UserActivationCode(User user, String code) {
this.user = user;
this.code = code;
}
public Long getId() {
return id;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public User getUser() {
return user;
}
public String getCode() {
return code;
}
}

View File

@ -1,26 +1,54 @@
package nl.andrewlalis.gymboard_api.service.auth; package nl.andrewlalis.gymboard_api.service.auth;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import nl.andrewlalis.gymboard_api.controller.dto.UserActivationPayload;
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload;
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
import nl.andrewlalis.gymboard_api.dao.auth.UserActivationCodeRepository;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.model.auth.User;
import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode;
import nl.andrewlalis.gymboard_api.util.ULID; import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Random;
@Service @Service
public class UserService { public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserActivationCodeRepository activationCodeRepository;
private final ULID ulid; private final ULID ulid;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
public UserService(UserRepository userRepository, ULID ulid, PasswordEncoder passwordEncoder) { @Value("${app.web-origin}")
private String webOrigin;
public UserService(
UserRepository userRepository,
UserActivationCodeRepository activationCodeRepository, ULID ulid,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender
) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.activationCodeRepository = activationCodeRepository;
this.ulid = ulid; this.ulid = ulid;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -31,15 +59,71 @@ public class UserService {
} }
@Transactional @Transactional
public UserResponse createUser(UserCreationPayload payload) { public UserResponse createUser(UserCreationPayload payload, boolean requireActivation) {
// TODO: Validate user payload. // TODO: Validate user payload.
User user = userRepository.save(new User( User user = userRepository.save(new User(
ulid.nextULID(), ulid.nextULID(),
true, // TODO: Change this to false once email activation is in. !requireActivation,
payload.email(), payload.email(),
passwordEncoder.encode(payload.password()), passwordEncoder.encode(payload.password()),
payload.name() payload.name()
)); ));
if (requireActivation) {
generateAndSendActivationCode(user);
}
return new UserResponse(user);
}
private void generateAndSendActivationCode(User user) {
Random random = new SecureRandom();
StringBuilder sb = new StringBuilder(127);
final String alphabet = "bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ23456789";
for (int i = 0; i < 127; i++) {
sb.append(alphabet.charAt(random.nextInt(alphabet.length())));
}
String rawCode = sb.toString();
UserActivationCode activationCode = activationCodeRepository.save(new UserActivationCode(user, rawCode));
// Send email.
String activationLink = webOrigin + "/activate?code=" + activationCode.getCode();
String emailContent = String.format(
"""
<p>Hello %s,</p>
<p>
Thank you for registering a new account at Gymboard!
</p>
<p>
Please click <a href="%s">here</a> to activate your account.
</p>
""",
user.getName(),
activationLink
);
MimeMessage msg = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8");
helper.setFrom("Gymboard <noreply@gymboard.io>");
helper.setSubject("Activate Your Gymboard Account");
helper.setTo(user.getEmail());
helper.setText(emailContent, true);
mailSender.send(msg);
} catch (MessagingException e) {
log.error("Error sending user activation email.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Transactional
public UserResponse activateUser(UserActivationPayload payload) {
UserActivationCode activationCode = activationCodeRepository.findByCode(payload.code())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
LocalDateTime cutoff = LocalDateTime.now().minusDays(1);
if (activationCode.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired.");
}
User user = activationCode.getUser();
user.setActivated(true);
userRepository.save(user);
return new UserResponse(user); return new UserResponse(user);
} }
} }

View File

@ -9,5 +9,11 @@ spring.jpa.show-sql=false
spring.task.execution.pool.core-size=3 spring.task.execution.pool.core-size=3
spring.task.execution.pool.max-size=10 spring.task.execution.pool.max-size=10
app.auth.private-key-location=./private_key.der spring.mail.host=127.0.0.1
spring.mail.port=1025
spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.timeout=10000
app.auth.private-key-location=./private_key.der
app.web-origin=http://localhost:9000

View File

@ -111,7 +111,9 @@ module.exports = configure(function (ctx) {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [], plugins: [
'Notify'
],
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations

View File

@ -14,6 +14,12 @@ export interface TokenCredentials {
password: string; password: string;
} }
export interface UserCreationPayload {
name: string;
email: string;
password: string;
}
class AuthModule { class AuthModule {
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000; private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
@ -35,6 +41,16 @@ class AuthModule {
clearTimeout(this.tokenRefreshTimer); clearTimeout(this.tokenRefreshTimer);
} }
public async register(payload: UserCreationPayload) {
const response = await api.post('/auth/register', payload);
console.log(response);
}
public async activateUser(code: string): Promise<User> {
const response = await api.post('/auth/activate', {code: code});
return response.data;
}
private async fetchNewToken(credentials: TokenCredentials): Promise<string> { private async fetchNewToken(credentials: TokenCredentials): Promise<string> {
const response = await api.post('/auth/token', credentials); const response = await api.post('/auth/token', credentials);
return response.data.token; return response.data.token;

View File

@ -28,6 +28,17 @@ export default boot(({ app }) => {
messages, messages,
}); });
// Set the locale to the preferred locale, if possible.
const userLocale = window.navigator.language;
if (userLocale === 'nl-NL') {
i18n.global.locale.value = userLocale;
} else {
i18n.global.locale.value = 'en-US';
}
// Temporary override if you want to test a particular locale.
i18n.global.locale.value = 'nl-NL';
// Set i18n instance on app // Set i18n instance on app
app.use(i18n); app.use(i18n);
}); });

View File

@ -10,14 +10,14 @@
<q-list> <q-list>
<q-item clickable v-close-popup @click="api.auth.logout(authStore)"> <q-item clickable v-close-popup @click="api.auth.logout(authStore)">
<q-item-section> <q-item-section>
<q-item-label>Log out</q-item-label> <q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
<q-btn <q-btn
color="primary" color="primary"
:label="$t('Login')" :label="$t('accountMenuItem.logIn')"
v-if="!authStore.loggedIn" v-if="!authStore.loggedIn"
no-caps no-caps
icon="person" icon="person"

View File

@ -3,6 +3,21 @@ export default {
language: 'Language', language: 'Language',
pages: 'Pages', pages: 'Pages',
}, },
registerPage: {
title: 'Create a Gymboard Account',
name: 'Name',
email: 'Email',
password: 'Password',
register: 'Register',
error: 'An error occurred.'
},
loginPage: {
title: 'Login to Gymboard',
email: 'Email',
password: 'Password',
logIn: 'Log in',
createAccount: 'Create an account'
},
indexPage: { indexPage: {
searchHint: 'Search for a Gym', searchHint: 'Search for a Gym',
}, },
@ -24,4 +39,8 @@ export default {
submit: 'Submit', submit: 'Submit',
}, },
}, },
accountMenuItem: {
logIn: 'Login',
logOut: 'Log out'
}
}; };

View File

@ -3,6 +3,21 @@ export default {
language: 'Taal', language: 'Taal',
pages: "Pagina's", pages: "Pagina's",
}, },
registerPage: {
title: 'Maak een nieuwe Gymboard account aan',
name: 'Naam',
email: 'E-mail',
password: 'Wachtwoord',
register: 'Registreren',
error: 'Er is een fout opgetreden.'
},
loginPage: {
title: 'Inloggen bij Gymboard',
email: 'E-mail',
password: 'Wachtwoord',
logIn: 'Inloggen',
createAccount: 'Account aanmaken'
},
indexPage: { indexPage: {
searchHint: 'Zoek een sportschool', searchHint: 'Zoek een sportschool',
}, },
@ -24,4 +39,8 @@ export default {
submit: 'Sturen', submit: 'Sturen',
}, },
}, },
accountMenuItem: {
logIn: 'Inloggen',
logOut: 'Uitloggen'
}
}; };

View File

@ -0,0 +1,40 @@
<template>
<StandardCenteredPage>
<h3 class="text-center">{{ statusText }}</h3>
</StandardCenteredPage>
</template>
<script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {onMounted, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import api from 'src/api/main';
import {sleep} from 'src/utils';
import {useI18n} from 'vue-i18n';
const router = useRouter();
const route = useRoute();
const t = useI18n().t;
const statusText = ref('');
statusText.value = t('activationPage.activating');
onMounted(async () => {
await sleep(500);
const code = route.query.code as string;
try {
const user = api.auth.activateUser(code);
statusText.value = t('activationPage.success');
console.log(user);
await sleep(2000);
await router.replace('/login');
} catch (error) {
console.error(error);
statusText.value = t('activationPage.failure');
}
});
</script>
<style scoped>
</style>

View File

@ -1,12 +1,13 @@
<template> <template>
<StandardCenteredPage> <StandardCenteredPage>
<h3 class="text-center">Login to Gymboard</h3> <h3 class="text-center">{{ $t('loginPage.title') }}</h3>
<q-form @submit="tryLogin" @reset="resetLogin"> <q-form @submit="tryLogin" @reset="resetLogin">
<SlimForm> <SlimForm>
<div class="row"> <div class="row">
<q-input <q-input
:label="$t('loginPage.email')" :label="$t('loginPage.email')"
v-model="loginModel.email" v-model="loginModel.email"
type="email"
class="col-12" class="col-12"
/> />
</div> </div>
@ -27,10 +28,15 @@
</q-input> </q-input>
</div> </div>
<div class="row"> <div class="row">
<q-btn type="submit" label="Log in" color="primary" class="q-mt-md col-12" no-caps/> <q-btn type="submit" :label="$t('loginPage.logIn')" color="primary" class="q-mt-md col-12" no-caps/>
</div> </div>
<div class="row"> <div class="row">
<q-btn flat no-caps label="Create an account" color="secondary" class="q-mt-md col-12" style="text-decoration: underline"/> <router-link
:to="{ path: '/register', query: route.query.next ? { next: route.query.next } : {} }"
class="q-mt-md text-primary text-center col-12"
>
{{ $t('loginPage.createAccount') }}
</router-link>
</div> </div>
</SlimForm> </SlimForm>
</q-form> </q-form>
@ -38,7 +44,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import SlimForm from 'components/SlimForm.vue'; import SlimForm from 'components/SlimForm.vue';
import {ref} from 'vue'; import {ref} from 'vue';
import api from 'src/api/main'; import api from 'src/api/main';
@ -56,7 +62,6 @@ const loginModel = ref({
const passwordVisible = ref(false); const passwordVisible = ref(false);
async function tryLogin() { async function tryLogin() {
console.log('logging in...');
try { try {
await api.auth.login(authStore, loginModel.value); await api.auth.login(authStore, loginModel.value);
const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/'; const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/';
@ -70,7 +75,6 @@ function resetLogin() {
loginModel.value.email = ''; loginModel.value.email = '';
loginModel.value.password = ''; loginModel.value.password = '';
} }
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,94 @@
<template>
<StandardCenteredPage>
<h3 class="text-center">{{ $t('registerPage.title') }}</h3>
<q-form @submit="tryRegister" @reset="resetForm">
<SlimForm>
<div class="row">
<q-input
:label="$t('registerPage.name')"
v-model="registerModel.name"
type="text"
class="col-12"
/>
</div>
<div class="row">
<q-input
:label="$t('registerPage.email')"
v-model="registerModel.email"
type="email"
class="col-12"
/>
</div>
<div class="row">
<q-input
:label="$t('registerPage.password')"
v-model="registerModel.password"
:type="passwordVisible ? 'text' : 'password'"
class="col-12"
>
<template v-slot:append>
<q-icon
:name="passwordVisible ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="passwordVisible = !passwordVisible"
/>
</template>
</q-input>
</div>
<div class="row">
<q-btn
type="submit"
:label="$t('registerPage.register')"
color="primary"
class="q-mt-md col-12"
no-caps
/>
</div>
</SlimForm>
</q-form>
</StandardCenteredPage>
</template>
<script setup lang="ts">
import SlimForm from 'components/SlimForm.vue';
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import api from 'src/api/main';
import {useRouter} from 'vue-router';
import {ref} from 'vue';
import {useQuasar} from 'quasar';
import {useI18n} from 'vue-i18n';
const router = useRouter();
const registerModel = ref({
name: '',
email: '',
password: ''
});
const passwordVisible = ref(false);
const t = useI18n().t;
const quasar = useQuasar();
async function tryRegister() {
try {
await api.auth.register(registerModel.value);
await router.push('/register/success');
} catch (error) {
quasar.notify({
message: t('registerPage.error'),
type: 'negative'
});
}
}
function resetForm() {
registerModel.value.name = '';
registerModel.value.email = '';
registerModel.value.password = '';
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,15 @@
<template>
<StandardCenteredPage>
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
<p>Check your email for the link to activate your account.</p>
<p>You may safely close this page.</p>
</StandardCenteredPage>
</template>
<script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
</script>
<style scoped>
</style>

View File

@ -6,9 +6,19 @@ import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue';
import GymHomePage from 'pages/gym/GymHomePage.vue'; import GymHomePage from 'pages/gym/GymHomePage.vue';
import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue'; import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue';
import TestingPage from 'pages/TestingPage.vue'; import TestingPage from 'pages/TestingPage.vue';
import LoginPage from 'pages/LoginPage.vue'; import LoginPage from 'pages/auth/LoginPage.vue';
import RegisterPage from "pages/auth/RegisterPage.vue";
import RegistrationSuccessPage from "pages/auth/RegistrationSuccessPage.vue";
import ActivationPage from "pages/auth/ActivationPage.vue";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
// Auth-related pages, which live outside the main layout.
{ path: '/login', component: LoginPage },
{ path: '/register', component: RegisterPage },
{ path: '/register/success', component: RegistrationSuccessPage },
{ path: '/activate', component: ActivationPage },
// Main app:
{ {
path: '/', path: '/',
component: MainLayout, component: MainLayout,
@ -26,7 +36,6 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
{ path: '/login', component: LoginPage },
// Always leave this as last one, // Always leave this as last one,
// but you can also remove it // but you can also remove it