diff --git a/build.gradle b/build.gradle index bcaf2ce..5065949 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' compile 'com.jcabi:jcabi-manifests:0.7.5' + implementation 'com.auth0:java-jwt:3.10.1' /* GraphQL */ implementation 'com.graphql-java-kickstart:graphql-spring-boot-starter:7.0.0' diff --git a/src/main/java/de/themorpheus/edu/gateway/graphql/dto/user/JwtResultDTO.java b/src/main/java/de/themorpheus/edu/gateway/graphql/dto/user/JwtResultDTO.java index 60ef43e..c32cc3c 100644 --- a/src/main/java/de/themorpheus/edu/gateway/graphql/dto/user/JwtResultDTO.java +++ b/src/main/java/de/themorpheus/edu/gateway/graphql/dto/user/JwtResultDTO.java @@ -14,5 +14,13 @@ public class JwtResultDTO { @NotNull @NotEmpty @NotBlank private String token; + @NotNull + private JwtStatus status; + + public enum JwtStatus { + INVALID, + EXPIRED, + VERIFIED + } } diff --git a/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/mutation/user/AuthenticationResolver.java b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/mutation/user/AuthenticationResolver.java index b9a5526..8407e37 100644 --- a/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/mutation/user/AuthenticationResolver.java +++ b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/mutation/user/AuthenticationResolver.java @@ -1,9 +1,17 @@ package de.themorpheus.edu.gateway.graphql.resolver.mutation.user; +import de.themorpheus.edu.gateway.graphql.dto.user.JwtRequestDTO; +import de.themorpheus.edu.gateway.graphql.dto.user.JwtResultDTO; import de.themorpheus.edu.gateway.graphql.dto.user.UserAuthDTO; import de.themorpheus.edu.gateway.graphql.dto.user.UserAuthResultDTO; import org.springframework.stereotype.Component; import javax.validation.Valid; +import de.themorpheus.edu.gateway.graphql.resolver.util.HeaderUtil; +import de.themorpheus.edu.gateway.graphql.resolver.util.JsonWebToken; +import de.themorpheus.edu.gateway.graphql.resolver.util.RefreshToken; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import graphql.kickstart.tools.GraphQLMutationResolver; import graphql.schema.DataFetchingEnvironment; @@ -11,7 +19,64 @@ public class AuthenticationResolver implements GraphQLMutationResolver { public UserAuthResultDTO authenticate(@Valid UserAuthDTO userAuth, DataFetchingEnvironment environment) { - return new UserAuthResultDTO(UserAuthResultDTO.UserAuthResultType.SUCCESS); + Optional optionalDeviceId = HeaderUtil.findHeader(environment, HeaderUtil.DEVICE_ID); + + deviceId: { + HeaderUtil.setCookie( + environment, + Map.of( + HeaderUtil.DEVICE_ID, optionalDeviceId.orElse("EXAMPLE_DEVICE_COOKIE"), + //HeaderUtil.CookieOption.SAME_SITE.getValue(), "Strict" //TODO: Activate + HeaderUtil.CookieOption.SAME_SITE.getValue(), "None" + ), + HeaderUtil.CookieOption.SECURE, + HeaderUtil.CookieOption.HTTP_ONLY + ); + } + + //TODO: Backend authentication + UserAuthResultDTO.UserAuthResultType resultType = UserAuthResultDTO.UserAuthResultType.SUCCESS; + UUID userId = UUID.randomUUID(); + + if (resultType == UserAuthResultDTO.UserAuthResultType.SUCCESS) { + refreshToken: { + HeaderUtil.setCookie( + environment, + Map.of( + HeaderUtil.REFRESH_TOKEN, RefreshToken.generate(userId), + HeaderUtil.CookieOption.SAME_SITE.getValue(), "None" //TODO: Strict + ), + HeaderUtil.CookieOption.SECURE, + HeaderUtil.CookieOption.HTTP_ONLY + ); + } + } + + return new UserAuthResultDTO(resultType); + } + + public JwtResultDTO jwt(@Valid JwtRequestDTO requestDTO, DataFetchingEnvironment environment) { + // Get cookie + String refreshTokenCookie = HeaderUtil.getCookie(environment, HeaderUtil.REFRESH_TOKEN); + if (refreshTokenCookie == null) return new JwtResultDTO(null, JwtResultDTO.JwtStatus.INVALID); //TODO: 403 Forbidden + + // Verify refresh token + RefreshToken.VerificationResult result = RefreshToken.verify(refreshTokenCookie); + + // Malformed or invalid + if (result.getStatus() == RefreshToken.VerificationStatus.INVALID || + result.getStatus() == RefreshToken.VerificationStatus.MALFORMED + ) return new JwtResultDTO(null, JwtResultDTO.JwtStatus.INVALID); //TODO: 403 Forbidden + + // Expired + if (result.getStatus() == RefreshToken.VerificationStatus.EXPIRED) + return new JwtResultDTO(null, JwtResultDTO.JwtStatus.EXPIRED); + + UUID userId = result.getUserId(); + + // Create jwt + String jwt = JsonWebToken.generate(userId); + return new JwtResultDTO(jwt, JwtResultDTO.JwtStatus.VERIFIED); } } diff --git a/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/HeaderUtil.java b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/HeaderUtil.java new file mode 100644 index 0000000..54f1369 --- /dev/null +++ b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/HeaderUtil.java @@ -0,0 +1,87 @@ +package de.themorpheus.edu.gateway.graphql.resolver.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Enumeration; +import java.util.Map; +import java.util.Optional; +import graphql.schema.DataFetchingEnvironment; +import graphql.servlet.context.DefaultGraphQLServletContext; + +public class HeaderUtil { + + public static final String DEVICE_ID = "device_id"; + public static final String REFRESH_TOKEN = "refresh_token"; + + public static final String SET_COOKIE = "set-cookie"; + public static final String COOKIE = "cookie"; + + public static Optional findHeader(DataFetchingEnvironment environment, String header) { + DefaultGraphQLServletContext context = environment.getContext(); + HttpServletRequest request = context.getHttpServletRequest(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String currentHeader = headerNames.nextElement(); + if (currentHeader.equalsIgnoreCase(header)) return Optional.of(request.getHeader(currentHeader)); + } + + return Optional.empty(); + } + + public static void setHeader(DataFetchingEnvironment environment, String header, String value) { + DefaultGraphQLServletContext context = environment.getContext(); + HttpServletResponse response = context.getHttpServletResponse(); + response.setHeader(header, value); + } + + public static void setCookie(DataFetchingEnvironment environment, Map cookies, CookieOption... options) { + DefaultGraphQLServletContext context = environment.getContext(); + HttpServletResponse response = context.getHttpServletResponse(); + StringBuilder value = new StringBuilder(); + cookies.forEach((cookieKey, cookieValue) -> { + value.append(cookieKey); + value.append('='); + value.append(cookieValue); + value.append(';'); + }); + + for (CookieOption option : options) { + if (option.isValueRequired()) throw new IllegalArgumentException("Consider passing this option via 'cookies': " + option); + value.append(option.getValue()); + value.append(';'); + } + + response.setHeader(SET_COOKIE, value.toString()); + } + + public static String getCookie(DataFetchingEnvironment environment, String key) { + Optional cookieOptional = HeaderUtil.findHeader(environment, COOKIE); + if (!cookieOptional.isPresent()) return null; + + String[] parts = cookieOptional.get().split(";"); + + for (String part : parts) + if (part.startsWith(key) && part.contains("=")) + return part.split("=")[1]; + + return null; + } + + @RequiredArgsConstructor + public enum CookieOption { + MAX_AGE(true, "Max-Age"), + EXPIRES(true, "Expires"), + PATH(true, "Path"), + DOMAIN(true, "Domain"), + SAME_SITE(true, "SameSite"), + SECURE(false, "Secure"), + HTTP_ONLY(false, "HttpOnly"); + + @Getter private final boolean valueRequired; + @Getter private final String value; + + } + +} diff --git a/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/JsonWebToken.java b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/JsonWebToken.java new file mode 100644 index 0000000..64350dc --- /dev/null +++ b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/JsonWebToken.java @@ -0,0 +1,72 @@ +package de.themorpheus.edu.gateway.graphql.resolver.util; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.SignatureVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +public class JsonWebToken { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private static final int RANDOM_SIZE = 128; + private static final String SECRET = "nrj5m5sui65sdu"; + private static final Algorithm ALGORITHM = Algorithm.HMAC512(SECRET); + + public static String generate(UUID userId) { + byte[] rand = new byte[RANDOM_SIZE]; + RANDOM.nextBytes(rand); + + return JWT.create() + .withIssuer("e-edu") + .withSubject("jwt") + .withClaim("userId", userId.toString()) + .withClaim("rand", Base64.getEncoder().encodeToString(rand)) + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(Date.from(Instant.now().plus(Duration.ofMinutes(15)))) + .sign(ALGORITHM); + } + + public static VerificationResult verify(String jwt) { + DecodedJWT decodedJWT = JWT.decode(jwt); + if (decodedJWT.getExpiresAt().after(Date.from(Instant.now()))) + return new VerificationResult(VerificationStatus.EXPIRED, null); + + try { + ALGORITHM.verify(decodedJWT); + } catch (SignatureVerificationException ignored) { + return new VerificationResult(VerificationStatus.INVALID, null); + } + + UUID userId; + try { + userId = UUID.fromString(decodedJWT.getClaim("userId").asString()); + } catch (IllegalArgumentException ignored) { + return new VerificationResult(VerificationStatus.MALFORMED, null); + } + + return new VerificationResult(VerificationStatus.VERIFIED, userId); + } + + @Data + @RequiredArgsConstructor + public static class VerificationResult { + + private final VerificationStatus status; + private final UUID userId; + + } + + public enum VerificationStatus { + VERIFIED, INVALID, EXPIRED, MALFORMED + } + +} diff --git a/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/RefreshToken.java b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/RefreshToken.java new file mode 100644 index 0000000..dc23a7a --- /dev/null +++ b/src/main/java/de/themorpheus/edu/gateway/graphql/resolver/util/RefreshToken.java @@ -0,0 +1,84 @@ +package de.themorpheus.edu.gateway.graphql.resolver.util; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.exceptions.SignatureVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.Calendar; +import java.util.Date; +import java.util.UUID; + +public class RefreshToken { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private static final int RANDOM_SIZE = 128; + private static final String SECRET = "mvpowrngiow34ng0"; + private static final Algorithm ALGORITHM = Algorithm.HMAC512(SECRET); + + public static String generate(UUID userId) { + byte[] rand = new byte[RANDOM_SIZE]; + RANDOM.nextBytes(rand); + + Calendar expirationDate = Calendar.getInstance(); + expirationDate.setTimeInMillis(System.currentTimeMillis()); + expirationDate.add(Calendar.YEAR, 1); + + return JWT.create() + .withIssuer("e-edu") + .withSubject("refresh_token") + .withClaim("userId", userId.toString()) + .withClaim("rand", Base64.getEncoder().encodeToString(rand)) + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(expirationDate.getTime()) + .sign(ALGORITHM); + } + + public static VerificationResult verify(String jwt) { + DecodedJWT decodedJWT; + + try { + decodedJWT = JWT.decode(jwt); + } catch (JWTDecodeException ignored) { + return new VerificationResult(VerificationStatus.MALFORMED, null); + } + + if (decodedJWT.getExpiresAt().before(Date.from(Instant.now()))) + return new VerificationResult(VerificationStatus.EXPIRED, null); + + try { + ALGORITHM.verify(decodedJWT); + } catch (SignatureVerificationException ignored) { + return new VerificationResult(VerificationStatus.INVALID, null); + } + + UUID userId; + try { + userId = UUID.fromString(decodedJWT.getClaim("userId").asString()); + } catch (IllegalArgumentException ignored) { + return new VerificationResult(VerificationStatus.MALFORMED, null); + } + + return new VerificationResult(VerificationStatus.VERIFIED, userId); + } + + @Data + @RequiredArgsConstructor + public static class VerificationResult { + + private final VerificationStatus status; + private final UUID userId; + + } + + public enum VerificationStatus { + VERIFIED, INVALID, EXPIRED, MALFORMED + } + +} diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 0c792c5..ba88de5 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -38,6 +38,8 @@ type Mutation { authenticate(userAuth: UserAuth): UserAuthResult + jwt(jwtRequest: JwtRequest): JwtResult + } # ================ Generic ================ @@ -128,6 +130,13 @@ input JwtRequest type JwtResult { token: String # JWT; ONLY stored in the frontend state manager! + status: String +} + +enum JwtStatus { + INVALID, + EXPIRED, + VERIFIED } # ================ Report MicroService ================