User.email confirmation

This commit is contained in:
Patrick Haßel 2024-11-07 13:24:10 +01:00
parent 43208bf37d
commit ac82b8a0ac
23 changed files with 550 additions and 8 deletions

View File

@ -6,3 +6,8 @@ spring.datasource.username=sa
spring.datasource.password=password
#-
#spring.jpa.hibernate.ddl-auto=create
#-
de.ph87.tools.email.host=mail.ph87.de
de.ph87.tools.email.username=_noreply_@ph87.de
de.ph87.tools.email.password=zOt4EmTTTGdjcNtb5hfN
de.ph87.tools.email.from=_noreply_@ph87.de

View File

@ -50,6 +50,12 @@
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@ -1,6 +1,12 @@
import {validateBoolean, validateDate, validateString} from "../common/validators";
import {UserPublic} from "./UserPublic";
export enum EmailStatus {
NOT_SET = 'NOT_SET',
CONFIRMATION_NEEDED = 'CONFIRMATION_NEEDED',
CONFIRMED = 'CONFIRMED',
}
export class UserPrivate extends UserPublic {
constructor(
@ -10,6 +16,7 @@ export class UserPrivate extends UserPublic {
name: string,
readonly password: boolean,
readonly email: string,
readonly emailConfirmed: boolean,
admin: boolean,
) {
super(publicUuid, name, admin);
@ -23,6 +30,7 @@ export class UserPrivate extends UserPublic {
validateString(json['name']),
validateBoolean(json['password']),
validateString(json['email']),
validateBoolean(json['emailConfirmed']),
validateBoolean(json['admin']),
);
}

View File

@ -11,6 +11,7 @@ import {Numbers} from "../tools/Numbers/Numbers";
import {GroupDeletedEvent} from "../group/events/GroupDeletedEvent";
import {GroupLeftEvent} from "../group/events/GroupLeftEvent";
import {UserLogoutEvent} from "./events/UserLogoutEvent";
import {validateBoolean} from "../common/validators";
function userPushMessageFromJson(json: any): object {
const type = json['_type_'];
@ -93,6 +94,10 @@ export class UserService {
this.api.postSingle(['User', 'changeEmail'], email, UserPrivate.fromJson, next);
}
confirmEmail(emailConfirmation: string, next?: Next<boolean>) {
this.api.postSingle(['User', 'confirmEmail'], emailConfirmation, validateBoolean, next);
}
goto(user: UserPublic) {
this.router.navigate(['User', user.publicUuid]);
}

View File

@ -7,6 +7,7 @@ import {UserComponent} from "./pages/user/user.component";
import {GroupsComponent} from "./pages/group/groups/groups.component";
import {GroupComponent} from "./pages/group/group/group.component";
import {NumbersComponent} from "./pages/tools/numbers/numbers.component";
import {EmailConfirmationComponent} from "./pages/email-confirmation/email-confirmation.component";
export const routes: Routes = [
{path: 'SolarSystemPrintout', component: SolarSystemPrintoutComponent},
@ -22,6 +23,7 @@ export const routes: Routes = [
{path: 'User/:publicUuid', component: UserComponent},
{path: 'Profile', component: ProfileComponent},
{path: 'emailConfirmation/:emailConfirmation', component: EmailConfirmationComponent},
// fallback
{path: '**', redirectTo: '/SolarSystem'},

View File

@ -0,0 +1,11 @@
<div *ngIf="confirmed === null" class="emailConfirmation emailConfirmationPending">
Bestätigung läuft...
</div>
<div *ngIf="confirmed === false" class="emailConfirmation emailConfirmationFailed">
Bestätigung fehlgeschlagen
</div>
<div *ngIf="confirmed === true" class="emailConfirmation emailConfirmationSuccess">
Bestätigung erfolgreich
</div>

View File

@ -0,0 +1,17 @@
.emailConfirmation {
text-align: center;
margin: 1em;
font-size: 200%;
}
.emailConfirmationPending {
color: gray;
}
.emailConfirmationFailed {
color: indianred;
}
.emailConfirmationSuccess {
color: green;
}

View File

@ -0,0 +1,50 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from "rxjs";
import {ActivatedRoute, Router} from "@angular/router";
import {GroupService} from "../../api/group/group.service";
import {UserService} from "../../api/User/user.service";
import {NgIf} from "@angular/common";
import {EmailStatus} from "../../api/User/UserPrivate";
@Component({
selector: 'app-email-confirmation',
standalone: true,
imports: [
NgIf
],
templateUrl: './email-confirmation.component.html',
styleUrl: './email-confirmation.component.less'
})
export class EmailConfirmationComponent implements OnInit, OnDestroy {
protected readonly subs: Subscription[] = [];
protected confirmed: boolean | null = null;
constructor(
protected readonly router: Router,
protected readonly activatedRoute: ActivatedRoute,
protected readonly groupService: GroupService,
protected readonly userService: UserService,
) {
// -
}
ngOnInit(): void {
this.subs.push(this.activatedRoute.params.subscribe(params => {
const emailConfirmation = params['emailConfirmation'];
if (emailConfirmation) {
this.userService.confirmEmail(emailConfirmation, confirmed => {
this.confirmed = confirmed;
});
}
}));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
}
protected readonly EmailStatus = EmailStatus;
}

View File

@ -36,9 +36,13 @@
<div class="tileInner">
<div class="tileTitle">
E-Mail
<span *ngIf="userService.user.email === ''" class="passwordNotSet">(nicht gesetzt))</span>
<span *ngIf="userService.user.email !== '' && !userService.user.emailConfirmed" class="passwordNotSet">(nicht bestätigt))</span>
<span *ngIf="userService.user.email !== '' && userService.user.emailConfirmed" class="passwordSet">(bestätigt)</span>
</div>
<div class="tileContent">
<app-text initial="" [placeholder]="userService.user.email" [editable]="true" (onChange)="userService.changeEmail($event)" [validator]="emailValidator"></app-text>
<app-text initial="" placeholder="Bestätigungscode eingeben..." [editable]="true" (onChange)="userService.confirmEmail($event)" *ngIf="userService.user.email !== '' && !userService.user.emailConfirmed"></app-text>
</div>
</div>
</div>

View File

@ -7,7 +7,7 @@ import {GroupListComponent} from "../group/shared/group-list/group-list.componen
import {Group} from "../../api/group/Group";
import {Subscription} from "rxjs";
import {GroupService} from "../../api/group/group.service";
import {UserPrivate} from "../../api/User/UserPrivate";
import {EmailStatus, UserPrivate} from "../../api/User/UserPrivate";
const USER_NAME_MIN_LENGTH = 2;
@ -117,4 +117,5 @@ export class ProfileComponent implements OnInit, OnDestroy {
});
}
protected readonly EmailStatus = EmailStatus;
}

View File

@ -0,0 +1,74 @@
package de.ph87.tools.email;
import de.ph87.tools.user.User;
import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import lombok.*;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Getter
@ToString
@Table(name = "`email`")
@NoArgsConstructor
public class Email {
@Id
private String uuid = UUID.randomUUID().toString();
@NonNull
@Column(nullable = false)
private ZonedDateTime queued = ZonedDateTime.now();
@NonNull
@ManyToOne(optional = false)
private User user;
@NonNull
@Column(nullable = false)
private String receiver;
@NonNull
@Column(nullable = false)
private String subject;
@Lob
@NonNull
@ToString.Exclude
@Column(nullable = false)
private String content;
@Setter
@Column
@Nullable
private ZonedDateTime tried;
@Setter
@Column
private int tries = 0;
@Setter
@Column
@Nullable
private ZonedDateTime sent;
@Setter
@NonNull
@Column(nullable = false)
private String status = "";
public Email(@NonNull final User user, @NonNull final String subject, @NonNull final String content) {
this.user = user;
this.receiver = user.getEmail();
this.subject = subject;
this.content = content;
}
public void addTry() {
tried = ZonedDateTime.now();
tries++;
}
}

View File

@ -0,0 +1,36 @@
package de.ph87.tools.email;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Properties;
@Data
@Component
@ConfigurationProperties(prefix = "de.ph87.tools.email")
public class EmailConfig {
private String host;
private int port = 465;
private boolean ssl = true;
private String username;
private String password;
private String from;
private String hrefBase = "http://localhost:4200";
public Properties getProperties() {
final Properties properties = new Properties();
properties.put("mail.smtp.host", host);
properties.put("mail.smtp.port", port);
properties.put("mail.smtp.ssl.enable", ssl);
return properties;
}
}

View File

@ -0,0 +1,52 @@
package de.ph87.tools.email;
import de.ph87.tools.user.User;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Getter
@ToString
public class EmailDto {
@NonNull
private final String uuid;
@NonNull
private final ZonedDateTime queued;
@NonNull
private final User user;
@NonNull
private final String receiver;
@NonNull
private final String subject;
@NonNull
@ToString.Exclude
private final String content;
@Nullable
private final ZonedDateTime sent;
@NonNull
@ToString.Exclude
private final String status;
public EmailDto(@NonNull final Email email) {
this.uuid = email.getUuid();
this.queued = email.getQueued();
this.user = email.getUser();
this.receiver = "%s <%s>".formatted(user.getName(), user.getEmail());
this.subject = email.getSubject();
this.content = email.getContent();
this.sent = email.getSent();
this.status = email.getStatus();
}
}

View File

@ -0,0 +1,18 @@
package de.ph87.tools.email;
import lombok.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.ZonedDateTime;
public interface EmailRepository extends ListCrudRepository<Email, String>, PagingAndSortingRepository<Email, String> {
@NonNull
@Query("select e from Email e where e.sent is null and (e.tried is null or e.tried <= :earliest) and e.tries < 5")
Page<Email> findNextToSend(@NonNull final ZonedDateTime earliest, @NonNull Pageable pageable);
}

View File

@ -0,0 +1,103 @@
package de.ph87.tools.email;
import de.ph87.tools.email.events.EmailQueuedEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.mail.*;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSender {
private final EmailService emailService;
private final EmailConfig emailConfig;
@NonNull
private final Thread thread = new Thread(this::run, getClass().getSimpleName());
private boolean stop = false;
@PostConstruct
public void postConstruct() {
thread.start();
}
@PreDestroy
public void preDestroy() throws InterruptedException {
synchronized (thread) {
stop = true;
wakeUp();
}
thread.join();
}
private void run() {
while (!stop) {
sendAllUnsent();
sleep();
}
}
private void sendAllUnsent() {
while (true) {
final Page<EmailDto> page = emailService.findNextToSend(10);
for (final EmailDto emailDto : page.getContent()) {
send(emailDto);
}
if (page.getTotalElements() - page.getNumberOfElements() <= 0) {
break;
}
}
}
private void sleep() {
try {
synchronized (thread) {
thread.wait();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private void send(@NonNull EmailDto emailDto) {
try {
final Session session = Session.getInstance(emailConfig.getProperties(), null);
final Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(emailConfig.getFrom()));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(emailDto.getReceiver()));
message.setSubject(emailDto.getSubject());
final MimeBodyPart mimeBodyPart = new MimeBodyPart();
mimeBodyPart.setContent(emailDto.getContent(), "text/html; charset=utf-8");
final Multipart multipart = new MimeMultipart();
multipart.addBodyPart(mimeBodyPart);
message.setContent(multipart);
Transport.send(message, emailConfig.getUsername(), emailConfig.getPassword());
emailDto = emailService.markSuccess(emailDto.getUuid());
log.info("Email sent: email={}", emailDto);
} catch (MessagingException e) {
emailDto = emailService.markFailure(emailDto.getUuid(), e.toString());
log.error("Failed to send email: email={}", emailDto, e);
}
}
@TransactionalEventListener(EmailQueuedEvent.class)
public void wakeUp() {
synchronized (thread) {
thread.notifyAll();
}
}
}

View File

@ -0,0 +1,83 @@
package de.ph87.tools.email;
import de.ph87.tools.email.events.EmailQueuedEvent;
import de.ph87.tools.user.User;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
import java.util.function.Consumer;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class EmailService {
private static final String EMAIL_CONTENT = "<html><body><h1>E-Mail Adresse bestätigen</h1><p>Hallo %USERNAME%,<br>bitte klicke auf folgenden Link um deine E-Mail Adresse zu bestätigen: <a href='%HREF_BASE%/emailConfirmation/%EMAIL_CONFIRMATION%'>BESTÄTIGEN</a></p><p>Alternativ kannst Du auch folgenden Code auf deiner Profilseite eingeben: <span style='font-family: monospace; background-color: white; color: blue'>%EMAIL_CONFIRMATION%</span></p></body></html>";
private final ApplicationEventPublisher applicationEventPublisher;
private final EmailRepository emailRepository;
private final EmailConfig emailConfig;
public void queue(@NonNull final User user, @NonNull final String subject, @NonNull final String content) {
final Email email = emailRepository.save(new Email(user, subject, content));
log.info("Email queued: {}", email);
applicationEventPublisher.publishEvent(new EmailQueuedEvent());
}
@NonNull
public Page<EmailDto> findNextToSend(final int maxCount) {
return emailRepository.findNextToSend(ZonedDateTime.now().minusMinutes(1), PageRequest.of(0, maxCount)).map(EmailDto::new);
}
@NonNull
public EmailDto markSuccess(@NonNull final String uuid) {
return modify(uuid, email -> {
email.addTry();
email.setSent(email.getTried());
email.setStatus("OK");
});
}
@NonNull
public EmailDto markFailure(@NonNull final String uuid, @NonNull final String error) {
return modify(uuid, email -> {
if (email.getSent() != null) {
log.error("Cannot mark email failure, because email is already successfully sent: email={}", email);
return;
}
email.addTry();
email.setStatus(error);
});
}
@NonNull
private EmailDto modify(@NonNull final String uuid, @NonNull final Consumer<Email> modifier) {
final Email email = emailRepository.findById(uuid).orElseThrow();
modifier.accept(email);
return new EmailDto(email);
}
public void queueEmailConfirmationEmail(@NonNull final User user) {
final String content = createEmailContent(user);
queue(user, "Patrix Tools: Email-Adresse bestätigen", content);
}
@NonNull
private String createEmailContent(@NonNull final User user) {
return EMAIL_CONTENT
.replaceAll("%USERNAME%", user.getName())
.replaceAll("%HREF_BASE%", emailConfig.getHrefBase())
.replaceAll("%EMAIL_CONFIRMATION%", user.getEmailConfirmation());
}
}

View File

@ -0,0 +1,10 @@
package de.ph87.tools.email.events;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public class EmailQueuedEvent {
}

View File

@ -1,8 +1,8 @@
package de.ph87.tools.tools.numbers;
import de.ph87.tools.group.GroupMapper;
import de.ph87.tools.group.access.GroupAccess;
import de.ph87.tools.group.access.GroupAccessService;
import de.ph87.tools.group.GroupMapper;
import de.ph87.tools.group.dto.GroupDto;
import de.ph87.tools.group.uuid.GroupUuid;
import de.ph87.tools.tools.numbers.uuid.NumbersUuid;

View File

@ -72,12 +72,20 @@ public class User extends UserPublicAbstract {
@Column(nullable = false)
private String password = "";
@Setter
@NonNull
@ToString.Exclude
@Column(nullable = false)
private String email = "";
@NonNull
@ToString.Exclude
@Column(nullable = false)
private String emailConfirmation = "";
@Setter
@Column(nullable = false)
private boolean emailConfirmed = false;
public User(@NonNull final String name) {
this.name = name;
}
@ -90,4 +98,17 @@ public class User extends UserPublicAbstract {
this.password = passwordEncoder.encode(password);
}
public void setEmail(@NonNull final String email) {
if (this.email.equals(email)) {
return;
}
this.email = email;
this.emailConfirmed = false;
if (this.email.isEmpty()) {
this.emailConfirmation = "";
} else {
this.emailConfirmation = UUID.randomUUID().toString();
}
}
}

View File

@ -55,6 +55,11 @@ public class UserController {
return userService.changeEmail(userUuid, email);
}
@PostMapping("confirmEmail")
public boolean confirmEmail(@NonNull @RequestBody final String emailConfirmation) {
return userService.confirmEmail(emailConfirmation);
}
@GetMapping("delete")
public void delete(@NonNull final UserPrivateUuid userUuid, @NonNull final HttpServletResponse response) {
userService.delete(userUuid, response);

View File

@ -39,6 +39,8 @@ public class UserPrivateDto extends UserPublicAbstract {
private final String email;
private final boolean emailConfirmed;
private final boolean admin;
public UserPrivateDto(@NonNull final User user) {
@ -48,6 +50,7 @@ public class UserPrivateDto extends UserPublicAbstract {
this.created = user.getCreated();
this.password = !user.getPassword().isEmpty();
this.email = obfuscateEmail(user.getEmail());
this.emailConfirmed = user.isEmailConfirmed();
this.admin = user.isAdmin();
}

View File

@ -17,4 +17,7 @@ public interface UserRepository extends ListCrudRepository<User, String> {
@NonNull
Optional<User> findByPrivateUuid(@NonNull String privateUuid);
@NonNull
Optional<User> findByEmailConfirmation(@NonNull String emailConfirmation);
}

View File

@ -1,6 +1,7 @@
package de.ph87.tools.user;
import de.ph87.tools.common.uuid.AbstractUuid;
import de.ph87.tools.email.EmailService;
import de.ph87.tools.group.GroupMapper;
import de.ph87.tools.group.GroupRepository;
import de.ph87.tools.user.push.UserPushService;
@ -57,6 +58,8 @@ public class UserService {
private final UserPushService userPushService;
private final EmailService emailService;
@NonNull
public UserPrivateDto login(@NonNull final UserLoginRequest loginRequest, @NonNull final HttpServletResponse response) {
final User user = userRepository.findByName(loginRequest.name).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
@ -93,7 +96,7 @@ public class UserService {
@NonNull
public UserPrivateDto changeName(@NonNull final UserPrivateUuid privateUuid, @NonNull final String name) {
return modify(privateUuid, user -> {
return modifyToDto(privateUuid, user -> {
if (user.getName().equals(name)) {
return;
}
@ -118,7 +121,7 @@ public class UserService {
@NonNull
public UserPrivateDto changePassword(@NonNull final UserPrivateUuid privateUuid, @NonNull final String password) {
return modify(privateUuid, user -> {
return modifyToDto(privateUuid, user -> {
if (password.length() < PASSWORD_MIN_LENGTH) {
log.warn("Cannot change User password: too short: length={}/{}, user={}", password.length(), PASSWORD_MIN_LENGTH, user);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
@ -134,16 +137,34 @@ public class UserService {
@NonNull
public UserPrivateDto changeEmail(@NonNull final UserPrivateUuid privateUuid, @NonNull final String email) {
return modify(privateUuid, user -> {
return modifyToDto(privateUuid, user -> {
if (!isEmailValid(email)) {
log.warn("Cannot change User email: not valid, user={}", user);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
if (user.getEmail().equals(email)) {
return;
}
user.setEmail(email);
log.info("User email changed: user={}", user);
emailService.queueEmailConfirmationEmail(user);
});
}
public boolean confirmEmail(@NonNull final String emailConfirmation) {
final User user = userRepository.findByEmailConfirmation(emailConfirmation).orElse(null);
if (user == null) {
log.warn("Failed to confirm email: emailConfirmation={}", emailConfirmation);
return false;
}
if (!user.isEmailConfirmed()) {
user.setEmailConfirmed(true);
log.info("User email confirmed: user={}", user);
push(user);
}
return true;
}
public void delete(@NonNull final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) {
final User user = userRepository.findByPrivateUuid(privateUuid.uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
deleteUnchecked(response, user);
@ -171,15 +192,19 @@ public class UserService {
}
@NonNull
private UserPrivateDto modify(@NonNull final UserPrivateUuid privateUuid, @NonNull Consumer<User> modifier) {
private UserPrivateDto modifyToDto(@NonNull final UserPrivateUuid privateUuid, @NonNull Consumer<User> modifier) {
final User user = userAccessService.access(privateUuid);
modifier.accept(user);
return push(user);
}
private @NonNull UserPrivateDto push(@NonNull final User user) {
final UserPrivateDto privateDto = new UserPrivateDto(user);
userPushService.push(user, privateDto);
return privateDto;
}
private void deleteUnchecked(final HttpServletResponse response, final User user) {
private void deleteUnchecked(@NonNull final HttpServletResponse response, @NonNull final User user) {
userRepository.delete(user);
log.info("User DELETED: {}", user);
writeUserUuidCookie(response, null);