8000 feat: Users can now use two-factor authentication with time-based one… by jekutzsche · Pull Request #840 · iris-connect/iris-client · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: Users can now use two-factor authentication with time-based one… #840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion iris-client-bff/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@
<mapstruct-version>1.5.2.Final</mapstruct-version>
<lombok-mapstruct-binding-version>0.2.0</lombok-mapstruct-binding-version>
<mapstruct-spring-version>0.1.2</mapstruct-spring-version>
<hibernate-search-version>6.1.5.Final</hibernate-search-version>
<totp.version>1.7.1</totp.version>
<greenmail-version>1.6.9</greenmail-version>
<testcontainers-version>1.17.2</testcontainers-version>
<hibernate-search-version>6.1.5.Final</hibernate-search-version>
</properties>

<profiles>
Expand Down Expand Up @@ -308,6 +309,13 @@
<version>1.0.2.3</version>
</dependency>

<!-- 2fa -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>${totp.version}</version>
</dependency>

<!-- for health checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package iris.client_bff.auth.db;

public enum AuthenticationStatus {
AUTHENTICATED, PRE_AUTHENTICATED_MFA_REQUIRED, PRE_AUTHENTICATED_ENROLLMENT_REQUIRED
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
Expand Down Expand Up @@ -56,14 +57,11 @@ public class DbAuthSecurityConfig extends WebSecurityConfigurerAdapter {
};

private final Environment env;

private final UserService userService;

private final JWTService jwtService;

private final AuthenticationEntryPoint authenticationEntryPoint;

private final AccessDeniedHandler accessDeniedHandler;
private final AuthenticationManagerBuilder authenticationManagerBuilder;

@Override
protected void configure(HttpSecurity http) throws Exception {
Expand Down Expand Up @@ -91,13 +89,17 @@ protected void configure(HttpSecurity http) throws Exception {
.antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT).permitAll()
.antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT_WITH_SLASH).permitAll()
.antMatchers(LOGIN, "/refreshtoken", "/error").permitAll()
.anyRequest().authenticated())
.antMatchers("/mfa/otp").hasAnyAuthority(AuthenticationStatus.PRE_AUTHENTICATED_MFA_REQUIRED.name(),
AuthenticationStatus.PRE_AUTHENTICATED_ENROLLMENT_REQUIRED.name())
.anyRequest().hasAnyAuthority(UserRole.ADMIN.name(), UserRole.USER.name()))
.logout(it -> it
.logoutUrl("/user/logout")
.addLogoutHandler(logoutHandler())
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT))
.permitAll())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

authenticationManagerBuilder.authenticationProvider(new OtpAuthenticationProvider 10000 (userService));
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
package iris.client_bff.auth.db;

import static iris.client_bff.users.UserRole.*;
import static java.util.Optional.*;
import static org.apache.commons.collections4.CollectionUtils.*;

import iris.client_bff.auth.db.jwt.JwtConstants;
import iris.client_bff.users.UserAccount;
import iris.client_bff.users.UserRole;
import lombok.EqualsAndHashCode;
import lombok.NonNull;

import java.util.EnumSet;
import java.util.List;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.Transient;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken;

@Transient
@EqualsAndHashCode(callSuper = true)
public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken<Jwt> {

private static final long serialVersionUID = -2363012036109039609L;

private static final EnumSet<UserRole> AUTHENTICATION_ROLES = EnumSet.of(ADMIN, USER);

private final String name;

public JwtAuthenticationToken(@NonNull Jwt jwt, @NonNull UserAccount principal) {

// By convention we expect that there exists only one authority and it represents the role
super(jwt, principal, null,
AuthorityUtils.createAuthorityList(principal.getRole().name()));
super(jwt, principal, null, determineAuthority(jwt, principal));

this.setAuthenticated(isAuthenticated(principal));
this.setAuthenticated(isNotEmpty(getAuthorities()));
this.name = jwt.getSubject();
}

Expand All @@ -43,7 +43,10 @@ public String getName() {
return this.name;
}

private boolean isAuthenticated(@NonNull UserAccount principal) {
return AUTHENTICATION_ROLES.contains(principal.getRole());
private static List<GrantedAuthority> determineAuthority(Jwt jwt, UserAccount principal) {
return ofNullable(jwt.getClaimAsString(JwtConstants.JWT_CLAIM_AUTH_STATUS))
.map(AuthorityUtils::createAuthorityList)
// By convention we expect that there exists only one authority and it represents the role
.orElseGet(() -> AuthorityUtils.createAuthorityList(principal.getRole().name()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package iris.client_bff.auth.db;

import static iris.client_bff.auth.db.MfAuthenticationProperties.MfAuthenticationOptions.*;

import lombok.Value;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;

@ConfigurationProperties(prefix = "security.auth.db.mfa")
@ConstructorBinding
@ConditionalOnProperty(
value = "security.auth",
havingValue = "db")
@Value
public class MfAuthenticationProperties {

private MfAuthenticationOptions option;

public boolean isMfaEnabled() {
return option != DISABLED;
}

public enum MfAuthenticationOptions {
ALWAYS, OPTIONAL_DEFAULT_TRUE, OPTIONAL_DEFAULT_FALSE, DISABLED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package iris.client_bff.auth.db;

import iris.client_bff.users.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.Objects;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@ConditionalOnProperty(
value = "security.auth",
havingValue = "db")
@RequiredArgsConstructor
@Slf4j
public class OtpAuthenticationProvider implements AuthenticationProvider {

private static final String USER_NOT_FOUND = "User: %s, not found";

private final UserService userService;

@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException {

var username = auth.getName();
var otp = Objects.toString(auth.getCredentials());
var user = userService.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(String.format(USER_NOT_FOUND, username)));

var valid = user.isMfaSecretEnrolled()
? userService.verifyOtp(user, otp)
: userService.finishEnrollment(user, otp);

if (!valid) {
throw new BadCredentialsException("Invalid verification code");
}

var resultAuth = new OtpAuthenticationToken(user, null);
resultAuth.setDetails(auth.getDetails());
log.debug("Authenticated user");

return resultAuth;
}

@Override
public boolean supports(Class<?> authentication) {
return OtpAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package iris.client_bff.auth.db;

import lombok.EqualsAndHashCode;
import lombok.Value;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.SpringSecurityCoreVersion;

/**
* @author Jens Kutzsche
*/
@Value
@EqualsAndHashCode(callSuper = false)
public class OtpAuthenticationToken extends AbstractAuthenticationToken {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

private Object principal;
private Object credentials;

public OtpAuthenticationToken(Object principal, Object credentials) {

super(null);

this.principal = principal;
this.credentials = credentials;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import io.vavr.control.Option;
import io.vavr.control.Try;
import iris.client_bff.auth.db.AuthenticationStatus;
import iris.client_bff.core.setting.Setting;
import iris.client_bff.core.setting.Setting.Name;
import iris.client_bff.core.setting.SettingsRepository;
Expand Down Expand Up @@ -41,7 +42,6 @@
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
Expand Down Expand Up @@ -102,18 +102,18 @@ public JwtDecoder getJwtDecoder() {
return decoder;
}

public ResponseCookie createJwtCookie(UserDetails user) {
public ResponseCookie createJwtCookie(String username) {

var refreshExpirationTime = jwtProperties.getRefresh().getExpirationTime();
var jwt = createToken(user, jwtProperties.getExpirationTime(), refreshExpirationTime, this::signToken);
return createJwtCookie(new JwtCookieData(username, null, jwtProperties.getCookieName(),
jwtProperties.getExpirationTime(), jwtProperties.getRefresh().getExpirationTime(), jwtProperties.isSetSecure(),
PATH, jwtProperties.getSameSiteStr(), this::signToken));
}

return ResponseCookie.from(jwtProperties.getCookieName(), jwt)
.maxAge(refreshExpirationTime.toSeconds())
.secure(jwtProperties.isSetSecure())
.path(PATH)
.httpOnly(true)
.sameSite(jwtProperties.getSameSiteStr())
.build();
public ResponseCookie createPreAuthJwtCookie(String username, AuthenticationStatus authStatus) {

var expirationTime = Duration.ofMinutes(5);
return createJwtCookie(new JwtCookieData(username, authStatus, jwtProperties.getCookieName(), expirationTime,
expirationTime, jwtProperties.isSetSecure(), PATH, jwtProperties.getSameSiteStr(), this::signToken));
}

public ResponseCookie createCleanJwtCookie() {
Expand All @@ -122,19 +122,13 @@ public ResponseCookie createCleanJwtCookie() {
return ResponseCookie.from(jwtProperties.getCookieName(), null).maxAge(0).path(PATH).build();
}

public ResponseCookie createRefreshCookie(UserDetails user) {
public ResponseCookie createRefreshCookie(String username) {

var refresh = jwtProperties.getRefresh();
var expirationTime = refresh.getExpirationTime();
var jwt = createToken(user, expirationTime, expirationTime, this::signRefreshToken);

return ResponseCookie.from(refresh.getCookieName(), jwt)
.maxAge(expirationTime.toSeconds())
.secure(jwtProperties.isSetSecure())
.path(REFRESH_PATH)
.httpOnly(true)
.sameSite(jwtProperties.getSameSiteStr())
.build();
return createJwtCookie(new JwtCookieData(username, null, refresh.getCookieName(), expirationTime, expirationTime,
jwtProperties.isSetSecure(), REFRESH_PATH, jwtProperties.getSameSiteStr(), this::signRefreshToken));
}

public ResponseCookie createCleanRefreshCookie() {
Expand Down Expand Up @@ -210,20 +204,35 @@ private OAuth2TokenValidatorResult isTokenWhitelisted(Jwt jwt) {
return OAuth2TokenValidatorResult.failure(error);
}

private String createToken(UserDetails user, Duration tokenExpirationDuration, Duration entityExpirationDuration,
Function<Builder, String> signFunction) {
private ResponseCookie createJwtCookie(JwtCookieData data) {

var username = user.getUsername();
var jwt = createToken(data.username(), data.authStatus, data.tokenExpirationDuration(),
data.cookieExpirationDuration(), data.signFunction());

return ResponseCookie.from(data.cookieName(), jwt)
.maxAge(data.cookieExpirationDuration().toSeconds())
.secure(data.secure())
.path(data.path())
.httpOnly(true)
.sameSite(data.sameSite())
.build();
}

private String createToken(String username, AuthenticationStatus authStatus, Duration tokenExpirationDuration,
Duration entityExpirationDuration, Function<Builder, String> signFunction) {

// By convention we expect that there exists only one authority and it represents the role
var role = user.getAuthorities().iterator().next().getAuthority();
var issuedAt = Instant.now();

var token = signFunction.apply(JWT.create()
var jwtBuilder = JWT.create()
.withSubject(username)
.withClaim(JWT_CLAIM_USER_ROLE, role)
.withIssuedAt(Date.from(issuedAt))
.withExpiresAt(Date.from(issuedAt.plus(tokenExpirationDuration))));
.withExpiresAt(Date.from(issuedAt.plus(tokenExpirationDuration)));

if (authStatus != null) {
jwtBuilder = jwtBuilder.withClaim(JWT_CLAIM_AUTH_STATUS, authStatus.toString());
}

var token = signFunction.apply(jwtBuilder);

saveToken(token, username, issuedAt.plus(entityExpirationDuration), issuedAt);

Expand Down Expand Up @@ -342,4 +351,14 @@ static class RefreshProperties {
enum SameSite {
Strict, Lax, None
}

static record JwtCookieData(String username,
AuthenticationStatus authStatus,
String cookieName,
Duration tokenExpirationDuration,
Duration cookieExpirationDuration,
boolean secure,
String path,
String sameSite,
Function<Builder, String> signFunction) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
class JwtConstants {
public class JwtConstants {

public static final String JWT_CLAIM_USER_ROLE = "role";
public static final String JWT_CLAIM_AUTH_STATUS = "AUTH_STATUS";
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ public void loginFailed(String key) {
var keyHash = DigestUtils.md5Hex(key);

var attempt = loginAttempts.findById(keyHash)
.map(it -> {
it.setWaitingTime(it.getWaitingTime().multipliedBy(properties.getWaitingTimeMultiplier()));
return it;
})
.map(it -> it.setWaitingTime(it.getWaitingTime().multipliedBy(properties.getWaitingTimeMultiplier())))
.orElseGet(() -> LoginAttempts.builder()
.reference(keyHash)
.nextWarningThreshold(properties.getFirstWarningThreshold())
Expand Down
Loading
0