Skip to content

Commit

Permalink
feat: refactor login, use "send email to login"
Browse files Browse the repository at this point in the history
  • Loading branch information
astappiev committed Aug 9, 2024
1 parent 8793f90 commit 89237d4
Show file tree
Hide file tree
Showing 14 changed files with 279 additions and 63 deletions.
8 changes: 8 additions & 0 deletions interweb-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@
<artifactId>quarkus-logging-sentry</artifactId>
</dependency>

<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
Expand All @@ -121,6 +125,10 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId>
</dependency>

<!-- Adds /q/health endpoint which implements MicroProfile Health specification -->
<!-- https://quarkus.io/guides/smallrye-health -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public final class Roles {
private Roles() {
}

public static final String ADMIN = "Admin";
public static final String USER = "User"; // Auth by username and password
public static final String APPLICATION = "Application"; // Auth by api key
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRe
}

QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
return User.findByName(identity.getPrincipal().getName()).map(principal -> {
return User.findByEmail(identity.getPrincipal().getName()).map(principal -> {
builder.setPrincipal(principal);
return builder.build();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package de.l3s.interweb.server.config;

import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;

import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;

public class ExceptionMappers {
@ServerExceptionMapper
public RestResponse<HttpError> mapException(BadRequestException x) {
return RestResponse.status(Response.Status.BAD_REQUEST, HttpError.of(x));
}

public static final class HttpError {
private String message;

public String getMessage() {
return message;
}

public static HttpError of(Exception e) {
HttpError error = new HttpError();
error.message = e.getMessage();
return error;
}

@Override
public String toString() {
return message;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ public class ChatMessage extends PanacheEntityBase {

@NotEmpty
@NotNull
@Column(columnDefinition = "TEXT")
public String content;

@CreationTimestamp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ public class ApiKey extends PanacheEntityBase implements Credential {

@NotEmpty
@NotNull
@Column(unique = true, length = LENGTH)
public String apikey;

@CreationTimestamp
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package de.l3s.interweb.server.features.user;

import java.security.Principal;
import java.util.Set;
import java.time.Instant;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import io.quarkus.elytron.security.common.BcryptUtil;
import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import com.fasterxml.jackson.annotation.JsonIgnore;

Expand All @@ -31,45 +32,24 @@ public class User extends PanacheEntityBase implements Principal {
@NotNull
public String email;

@NotEmpty
@NotNull
@Schema(writeOnly = true)
@JsonIgnore
public String password;

@NotNull
@Schema(readOnly = true)
public String role = Roles.USER;

@JsonIgnore
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true)
public Set<Token> tokens;
@NotNull
public boolean approved = false;

protected User() {
}
@UpdateTimestamp
public Instant updated;

/**
* Adds a new user to the database
*/
public static Uni<User> add(String email, String password) {
User user = new User();
user.email = email;
user.password = BcryptUtil.bcryptHash(password);
return user.persist();
}
@CreationTimestamp
public Instant created;

public static Uni<User> findByName(String name) {
return find("email", name).firstResult();
protected User() {
}

public static Uni<User> findByNameAndPassword(String name, String password) {
return findByName(name).onItem().ifNotNull().transform(user -> {
if (BcryptUtil.matches(password, user.password)) {
return user;
}

return null;
});
public static Uni<User> findByEmail(String name) {
return find("email", name).firstResult();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package de.l3s.interweb.server.features.user;

import java.time.Duration;
import java.time.Instant;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
import io.quarkus.security.credential.Credential;
import io.smallrye.mutiny.Uni;
import org.hibernate.annotations.CreationTimestamp;

import com.fasterxml.jackson.annotation.JsonIgnore;

import de.l3s.interweb.core.util.StringUtils;

@Entity
@Cacheable
@Table(name = "user_token")
public class UserToken extends PanacheEntityBase implements Credential {

public enum Type {
login(Duration.ofHours(6), 32);

private final Duration duration;
private final int size;

Type(Duration duration, int size) {
this.duration = duration;
this.size = size;
}
}

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;

@JsonIgnore
@ManyToOne(optional = false, fetch = FetchType.EAGER)
public User user;

@NotEmpty
@NotNull
public String type;

@NotEmpty
@NotNull
public String token;

@CreationTimestamp
public Instant created;

public UserToken() {
// required for Panache
}

public static UserToken generate(Type type) {
UserToken key = new UserToken();
key.type = type.name();
key.token = StringUtils.randomAlphanumeric(type.size);
return key;
}

public static Uni<UserToken> findByToken(Type type, String token) {
return find("type = ?1 and token = ?2 and created > ?3", type.name(), token, Instant.now().minus(type.duration)).firstResult();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.l3s.interweb.server.features.user;

import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
Expand All @@ -8,14 +9,19 @@
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;

import io.quarkus.hibernate.reactive.panache.common.WithTransaction;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.reactive.ReactiveMailer;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;

import de.l3s.interweb.server.Roles;

Expand All @@ -24,6 +30,39 @@
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class UsersResource {
private static final Logger log = Logger.getLogger(UsersResource.class);

private static final String NEW_USER_EMAIL_SUBJECT = "Interweb: New user awaiting approval";
private static final String NEW_USER_EMAIL_BODY = """
There is a new user awaiting approval:
%s
Best regards,
Interweb Team
""";

private static final String LOGIN_APPROVAL_REQUIRED = "Thank you for registration, unfortunately your account is not yet approved. Please wait until we have reviewed your registration.";
private static final String LOGIN_EMAIL_SUBJECT = "Interweb: Login Link";
private static final String LOGIN_EMAIL_BODY = """
Hello,
To login to Interweb, please click the following link:
%s
This link is valid for 6 hours.
Best regards,
Interweb Team
""";

@ConfigProperty(name = "interweb.admin.email")
String adminEmail;

@ConfigProperty(name = "interweb.auto-approve.pattern")
String autoApprovePattern;

@Inject
ReactiveMailer mailer;

@Context
SecurityIdentity securityIdentity;
Expand All @@ -32,20 +71,67 @@ public class UsersResource {
@Path("/register")
@WithTransaction
@Operation(summary = "Register a new user", description = "Use this method to register a new user")
public Uni<User> register(@Valid CreateUser user) {
return User.findByName(user.email)
.onItem().ifNotNull().failWith(() -> new BadRequestException("User already exists"))
.chain(() -> User.add(user.email, user.password));
public Uni<String> register(@Valid CreateUser user, @Context UriInfo uriInfo) {
return login(user.email, uriInfo);
}

@GET
@Path("/login")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Request JWT token for the given email and password", description = "Use this method to login to the app and manage tokens")
public Uni<String> login(@NotEmpty @QueryParam("email") String email, @NotEmpty @QueryParam("password") String password) {
return User.findByNameAndPassword(email, password)
.onItem().ifNotNull().transform(user -> Jwt.upn(user.getName()).groups(Roles.USER).sign())
.onItem().ifNull().failWith(() -> new BadRequestException("No user found or password is incorrect"));
@Operation(summary = "Request JWT token for the given email", description = "Use this method to login to the app and manage tokens")
public Uni<String> login(@NotEmpty @QueryParam("email") String email, @Context UriInfo uriInfo) {
return findOrCreateUser(email).chain(user -> {
if (!user.approved) {
return Uni.createFrom().failure(new BadRequestException(LOGIN_APPROVAL_REQUIRED));
} else {
return createAndSendToken(user, uriInfo);
}
}).chain(() -> Uni.createFrom().item("The login link has been sent to your email."));
}

private Uni<User> findOrCreateUser(String email) {
return User.findByEmail(email).onItem().ifNull().switchTo(() -> createUser(email).call(user -> {
if (!user.approved) {
return mailer.send(Mail.withText(adminEmail, NEW_USER_EMAIL_SUBJECT, NEW_USER_EMAIL_BODY.formatted(user.email)));
}

return Uni.createFrom().voidItem();
}));
}

private Uni<Void> createAndSendToken(User user, UriInfo uriInfo) {
return createToken(user)
.chain(token -> {
log.infof("Login token %s created for user %s", token.token, user.email);
String tokenUrl = uriInfo.getBaseUri() + "jwt?token=" + token.token;
return mailer.send(Mail.withText(user.email, LOGIN_EMAIL_SUBJECT, LOGIN_EMAIL_BODY.formatted(tokenUrl)));
});
}

@WithTransaction
protected Uni<User> createUser(String email) {
User user = new User();
user.email = email;
user.approved = email.matches(autoApprovePattern);
user.role = Roles.USER;
return user.persist();
}

@WithTransaction
protected Uni<UserToken> createToken(User user) {
UserToken token = UserToken.generate(UserToken.Type.login);
token.user = user;
return token.persist();
}

@GET
@Path("/jwt")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Request JWT token for the given email and password")
public Uni<String> jwt(@NotEmpty @QueryParam("token") String token) {
return UserToken.findByToken(UserToken.Type.login, token)
.onItem().ifNotNull().transform(loginToken -> Jwt.upn(loginToken.user.getName()).groups(loginToken.user.role).sign())
.onItem().ifNull().failWith(() -> new BadRequestException("The token is invalid or expired"));
}

@GET
Expand All @@ -56,6 +142,6 @@ public User me() {
return (User) securityIdentity.getPrincipal();
}

public record CreateUser(@NotNull @NotEmpty @Email @Size(max = 255) String email, @NotNull @NotEmpty @Size(max = 255) String password) {
public record CreateUser(@NotNull @NotEmpty @Email @Size(max = 255) String email) {
}
}
Loading

0 comments on commit 89237d4

Please sign in to comment.