Przeglądaj źródła

二次认证优化

MaxKey 2 miesięcy temu
rodzic
commit
95a7c0daa1
41 zmienionych plików z 1415 dodań i 21 usunięć
  1. 15 1
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/SignPrincipal.java
  2. 1 1
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/jwt/AuthJwt.java
  3. 3 1
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/jwt/AuthJwtService.java
  4. 6 0
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/SessionManager.java
  5. 33 5
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/InMemorySessionManager.java
  6. 36 1
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/RedisSessionManager.java
  7. 31 0
      maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/SessionManagerImpl.java
  8. 26 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/AbstractAuthenticationProvider.java
  9. 127 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/TwoFactorAuthenticationProvider.java
  10. 90 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorEmailAuthenticationProvider.java
  11. 88 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorMobileAuthenticationProvider.java
  12. 84 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorTotpAuthenticationProvider.java
  13. 4 0
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/realm/AbstractAuthenticationRealm.java
  14. 58 1
      maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/autoconfigure/AuthnProviderAutoConfiguration.java
  15. 30 0
      maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsJwt.java
  16. 15 0
      maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsLoginType.java
  17. 27 0
      maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsTwoFactor.java
  18. 2 0
      maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/LoginService.java
  19. 12 0
      maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/LoginServiceImpl.java
  20. 2 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/AbstractOtpAuthn.java
  21. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/CapOtpAuthn.java
  22. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/CounterBasedOtpAuthn.java
  23. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/HotpOtpAuthn.java
  24. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/MailOtpAuthn.java
  25. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/MobileOtpAuthn.java
  26. 6 0
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/RsaOtpAuthn.java
  27. 8 4
      maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/TimeBasedOtpAuthn.java
  28. 6 0
      maxkey-starter/maxkey-starter-sms/src/main/java/org/dromara/maxkey/password/sms/SmsOtpAuthn.java
  29. 230 2
      maxkey-web-frontend/maxkey-web-app/package-lock.json
  30. 8 3
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts
  31. 6 0
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/passport-routing.module.ts
  32. 12 2
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/passport.module.ts
  33. 101 0
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.html
  34. 75 0
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.less
  35. 23 0
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.spec.ts
  36. 212 0
      maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.ts
  37. 4 0
      maxkey-web-frontend/maxkey-web-app/src/app/service/authn.service.ts
  38. 1 0
      maxkey-web-frontend/maxkey-web-app/src/app/shared/consts.ts
  39. 1 0
      maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json
  40. 1 0
      maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json
  41. 1 0
      maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-TW.json

+ 15 - 1
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/SignPrincipal.java

@@ -37,6 +37,8 @@ public class SignPrincipal implements  UserDetails {
     
     String sessionId;
     
+    int   	twoFactor;
+    
     List<GrantedAuthority> grantedAuthority;
     
     List<GrantedAuthority> grantedAuthorityApps;
@@ -204,7 +206,19 @@ public class SignPrincipal implements  UserDetails {
         }  
     }
 
-    @Override
+    public int getTwoFactor() {
+		return twoFactor;
+	}
+
+	public void setTwoFactor(int twoFactor) {
+		this.twoFactor = twoFactor;
+	}
+	
+	public void clearTwoFactor() {
+		this.twoFactor = 0;
+	}
+
+	@Override
 	public String toString() {
 		StringBuilder builder = new StringBuilder();
 		builder.append("Principal [username=");

+ 1 - 1
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/jwt/AuthJwt.java

@@ -104,7 +104,7 @@ public class AuthJwt implements Serializable {
 		this.email = principal.getUserInfo().getEmail();
 		this.instId = principal.getUserInfo().getInstId();
 		this.instName = principal.getUserInfo().getInstName();
-		
+		this.twoFactor =principal.getTwoFactor();
 		this.authorities = new ArrayList<>();
 		for(GrantedAuthority grantedAuthority :authentication.getAuthorities()) {
 			this.authorities.add(grantedAuthority.getAuthority());

+ 3 - 1
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/jwt/AuthJwtService.java

@@ -22,6 +22,7 @@ import java.util.Date;
 
 import org.apache.commons.lang3.StringUtils;
 import org.dromara.maxkey.authn.SignPrincipal;
+import org.dromara.maxkey.constants.ConstsJwt;
 import org.dromara.maxkey.crypto.jwt.Hmac512Service;
 import org.dromara.maxkey.entity.idm.UserInfo;
 import org.dromara.maxkey.web.WebContext;
@@ -61,7 +62,8 @@ public class AuthJwtService {
 				.expirationTime(expirationTime)
 				.claim("locale", userInfo.getLocale())
 				.claim("kid", Hmac512Service.MXK_AUTH_JWK)
-				.claim("institution", userInfo.getInstId())
+				.claim(ConstsJwt.USER_ID, userInfo.getId())
+				.claim(ConstsJwt.INST_ID, userInfo.getInstId())
 				.build();
 		
 		return signedJWT(jwtClaims);

+ 6 - 0
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/SessionManager.java

@@ -41,4 +41,10 @@ public interface SessionManager {
     public void terminate(String sessionId,String userId,String username);
     
     public void visited(String sessionId , VisitedDto visited);
+    
+    public  void createTwoFactor(String sessionId, Session session);
+    
+    public  Session removeTwoFactor(String sessionId);
+    
+    public  Session getTwoFactor(String sessionId);
 }

+ 33 - 5
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/InMemorySessionManager.java

@@ -38,21 +38,30 @@ public class InMemorySessionManager implements SessionManager{
     static final 	long 	CACHE_MAXIMUM_SIZE 	= 2000000;
     protected 		int 	validitySeconds 	= 60 * 30; //default 30 minutes.
     
-	protected  static  Cache<String, Session> sessionStore = 
-        	        Caffeine.newBuilder()
-        	            .expireAfterWrite(10, TimeUnit.MINUTES)
-        	            .maximumSize(CACHE_MAXIMUM_SIZE)
-        	            .build();
+	Cache<String, Session> sessionStore;
+	
+	Cache<String, Session> sessionTwoFactorStore;
 	
 	public InMemorySessionManager(int validitySeconds) {
         super();
         this.validitySeconds = validitySeconds;
+        if(validitySeconds > 0) {
         sessionStore = 
                 Caffeine.newBuilder()
                     .expireAfterWrite(validitySeconds, TimeUnit.SECONDS)
                     .maximumSize(CACHE_MAXIMUM_SIZE)
                     .build();
+        }else {
+        	sessionStore = Caffeine.newBuilder()
+		            .expireAfterWrite(10, TimeUnit.MINUTES)
+		            .maximumSize(CACHE_MAXIMUM_SIZE)
+		            .build();
+        }
         
+        sessionTwoFactorStore = Caffeine.newBuilder()
+            	.expireAfterWrite(10, TimeUnit.MINUTES)
+            	.maximumSize(CACHE_MAXIMUM_SIZE)
+            	.build();
     }
 
     @Override
@@ -127,5 +136,24 @@ public class InMemorySessionManager implements SessionManager{
 		    _logger.debug("session {} store visited  {} ." , sessionId , visited);
 		}
 	}
+	
+	@Override
+	public void createTwoFactor(String sessionId, Session session) {
+		session.setExpiredTime(session.getLastAccessTime().plusSeconds(validitySeconds));
+		sessionTwoFactorStore.put(sessionId, session);
+	}
+
+	@Override
+	public Session removeTwoFactor(String sessionId) {
+		Session session = sessionTwoFactorStore.getIfPresent(sessionId);	
+		sessionTwoFactorStore.invalidate(sessionId);
+		return session;
+	}
+
+	@Override
+	public Session getTwoFactor(String sessionId) {
+		Session session = sessionTwoFactorStore.getIfPresent(sessionId);
+		return session;
+	}
 
 }

+ 36 - 1
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/RedisSessionManager.java

@@ -35,9 +35,14 @@ public class RedisSessionManager implements SessionManager {
 	
     protected int validitySeconds = 60 * 30; //default 30 minutes.
     
+    int twoFactorValidity 	= 10 * 60; //default 10 minutes.
+    
 	RedisConnectionFactory connectionFactory;
 	
-	public static String PREFIX="MXK_SESSION_";
+	public static final String PREFIX = "MXK_SESSION_";
+	
+	public static final String PREFIX_TWOFACTOR = "mxk:session:twofactor:%s";
+	
 	
 	public String getKey(String sessionId) {
 		return PREFIX + sessionId;
@@ -150,4 +155,34 @@ public class RedisSessionManager implements SessionManager {
 		}
 	}
 	
+	public String formatTwoFactorKey(String sessionId) {
+		return PREFIX_TWOFACTOR.formatted(sessionId) ;
+	}
+	
+	@Override
+	public void createTwoFactor(String sessionId, Session session) {
+		session.setExpiredTime(session.getLastAccessTime().plusSeconds(validitySeconds));
+		RedisConnection conn = connectionFactory.getConnection();
+		conn.setexObject( formatTwoFactorKey(sessionId), twoFactorValidity, session);
+		conn.close();
+		
+	}
+
+	@Override
+	public Session removeTwoFactor(String sessionId) {
+		RedisConnection conn = connectionFactory.getConnection();
+		Session ticket = conn.getObject(formatTwoFactorKey(sessionId));
+		conn.delete(formatTwoFactorKey(sessionId));
+		conn.close();
+		return ticket;
+	}
+
+	@Override
+	public Session getTwoFactor(String sessionId) {
+		RedisConnection conn = connectionFactory.getConnection();
+        Session session = conn.getObject(formatTwoFactorKey(sessionId));
+        conn.close();
+        return session;
+	}
+	
 }

+ 31 - 0
maxkey-authentications/maxkey-authentication-core/src/main/java/org/dromara/maxkey/authn/session/impl/SessionManagerImpl.java

@@ -207,4 +207,35 @@ public class SessionManagerImpl implements SessionManager{
 			redisSessionManager.visited(sessionId,visited);
 		}
 	}
+	
+	@Override
+	public void createTwoFactor(String sessionId, Session session) {
+		if(isRedis) {
+			redisSessionManager.createTwoFactor(sessionId, session);
+		}else {
+			inMemorySessionManager.createTwoFactor(sessionId, session);
+		}
+	}
+
+	@Override
+	public Session removeTwoFactor(String sessionId) {
+		Session session = null;
+		if(isRedis) {
+			session = redisSessionManager.removeTwoFactor(sessionId);
+		}else {
+			session = inMemorySessionManager.removeTwoFactor(sessionId);
+		}
+		return session;
+	}
+
+	@Override
+	public Session getTwoFactor(String sessionId) {
+		Session session = null;
+		if(isRedis) {
+			session = redisSessionManager.getTwoFactor(sessionId);
+		}else {
+			session = inMemorySessionManager.getTwoFactor(sessionId);
+		}
+		return session;
+	}
 }

+ 26 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/AbstractAuthenticationProvider.java

@@ -20,6 +20,7 @@ package org.dromara.maxkey.authn.provider;
 import java.util.ArrayList;
 import java.util.List;
 
+import org.apache.commons.lang3.StringUtils;
 import org.dromara.maxkey.authn.LoginCredential;
 import org.dromara.maxkey.authn.SignPrincipal;
 import org.dromara.maxkey.authn.jwt.AuthTokenService;
@@ -30,6 +31,7 @@ import org.dromara.maxkey.authn.web.AuthorizationUtils;
 import org.dromara.maxkey.configuration.ApplicationConfig;
 import org.dromara.maxkey.constants.ConstsLoginType;
 import org.dromara.maxkey.constants.ConstsStatus;
+import org.dromara.maxkey.constants.ConstsTwoFactor;
 import org.dromara.maxkey.entity.idm.UserInfo;
 import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn;
 import org.dromara.maxkey.password.onetimepwd.MailOtpAuthnService;
@@ -93,6 +95,10 @@ public abstract class AbstractAuthenticationProvider {
 
     public abstract Authentication doAuthenticate(LoginCredential authentication);
 
+    public Authentication doTwoFactorAuthenticate(LoginCredential credential , UserInfo user) {
+    	return null;
+    }
+    
     @SuppressWarnings("rawtypes")
     public boolean supports(Class authentication) {
         return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
@@ -147,6 +153,13 @@ public abstract class AbstractAuthenticationProvider {
          */
         session.setAuthentication(authenticationToken);
 
+        if(credential.getAuthType().equalsIgnoreCase(AuthType.NORMAL) 
+        		&& userInfo.getAuthnType() > ConstsTwoFactor.NONE ) {
+        	//用户配置二次认证
+        	principal.setTwoFactor(userInfo.getAuthnType());
+        	this.sessionManager.createTwoFactor(session.getId(), session);
+        }
+        
         //create session
         this.sessionManager.create(session.getId(), session);
 
@@ -258,5 +271,18 @@ public abstract class AbstractAuthenticationProvider {
     	}
         return true;
     }
+    
+    /**
+     * check input otp empty.
+     * 
+     * @param password String
+     * @return
+     */
+    protected boolean emptyOtpCaptchaValid(String otpCaptcha) {
+        if (StringUtils.isBlank(otpCaptcha)) {
+            throw new BadCredentialsException(WebContext.getI18nValue("login.error.otpCaptcha.null"));
+        }
+        return true;
+    }
 
 }

+ 127 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/TwoFactorAuthenticationProvider.java

@@ -0,0 +1,127 @@
+
+
+ 
+
+package org.dromara.maxkey.authn.provider.twofactor;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.dromara.maxkey.authn.LoginCredential;
+import org.dromara.maxkey.authn.SignPrincipal;
+import org.dromara.maxkey.authn.jwt.AuthTokenService;
+import org.dromara.maxkey.authn.provider.AbstractAuthenticationProvider;
+import org.dromara.maxkey.authn.realm.AbstractAuthenticationRealm;
+import org.dromara.maxkey.authn.session.Session;
+import org.dromara.maxkey.authn.session.SessionManager;
+import org.dromara.maxkey.authn.web.AuthorizationUtils;
+import org.dromara.maxkey.constants.ConstsJwt;
+import org.dromara.maxkey.constants.ConstsLoginType;
+import org.dromara.maxkey.constants.ConstsTwoFactor;
+import org.dromara.maxkey.entity.idm.UserInfo;
+import org.dromara.maxkey.persistence.service.LoginService;
+import org.dromara.maxkey.web.WebConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import com.nimbusds.jwt.JWTClaimsSet;
+
+/**
+ * TwoFactor Authentication provider.双因素认证提供者
+ * 
+ * @author Crystal.Sea
+ *
+ */
+public class TwoFactorAuthenticationProvider extends AbstractAuthenticationProvider {
+    private static final Logger logger =LoggerFactory.getLogger(TwoFactorAuthenticationProvider.class);
+
+    Map<String,AbstractAuthenticationProvider> twoFactorProvider = new HashMap<>();
+    
+    public String getProviderName() {
+        return "twoFactor" + PROVIDER_SUFFIX;
+    }
+    
+    public TwoFactorAuthenticationProvider(
+    		AbstractAuthenticationRealm authenticationRealm,
+    	    SessionManager sessionManager,
+    	    LoginService loginService,
+    	    AuthTokenService authTokenService) {
+		this.authenticationRealm = authenticationRealm;
+		this.sessionManager = sessionManager;
+		this.authTokenService = authTokenService;
+	}
+    
+    public void addProvider(int twoFactor,AbstractAuthenticationProvider provider) {
+    	twoFactorProvider.put(twoFactor+"", provider);
+    }
+
+    @Override
+	public Authentication doAuthenticate(LoginCredential credential) {
+    	logger.debug("Credential {}" , credential);
+		emptyOtpCaptchaValid(credential.getOtpCaptcha());
+        try {
+	        if(authTokenService.validateJwtToken(credential.getJwtToken())) {
+	 			//解析refreshToken,转换会话id
+	 			JWTClaimsSet claim = authTokenService.resolve(credential.getJwtToken());
+	 			String sessionId = claim.getJWTID();
+	 			String userId = claim.getClaim(ConstsJwt.USER_ID).toString();
+	 			//String style = claim.getClaim(AuthorizationUtils.STYLE).toString();
+	 			//尝试刷新会话
+	 			logger.trace("Try to  get user {} , sessionId [{}]" , userId, sessionId);
+	 			Session session  = sessionManager.getTwoFactor(sessionId);
+		 		if(session != null) {//有会话
+		 			Authentication twoFactorAuth  = null;
+		 			SignPrincipal principal =(SignPrincipal)  session.getAuthentication().getPrincipal();
+		 			String loginType;
+		 			switch(principal.getTwoFactor()) {
+			 			case ConstsTwoFactor.TOTP -> {
+			 				loginType = ConstsLoginType.TwoFactor.TWO_FACTOR_TOTP;
+			 			}
+			 			case ConstsTwoFactor.EMAIL -> {
+			 				loginType = ConstsLoginType.TwoFactor.TWO_FACTOR_EMAIL;
+			 			}
+			 			case ConstsTwoFactor.SMS -> {
+			 				loginType = ConstsLoginType.TwoFactor.TWO_FACTOR_MOBILE;
+			 			}
+			 			default ->{
+			 				loginType = ConstsLoginType.TwoFactor.TWO_FACTOR_TOTP;
+			 			}
+		 			}
+		 			logger.debug("loginType {}",loginType);
+		 			AbstractAuthenticationProvider authenticationProvider = twoFactorProvider.get(principal.getTwoFactor()+"");
+		 			logger.debug("Provider {}",authenticationProvider.getProviderName());
+		 			UserInfo user = authenticationRealm.loadUserInfoById(userId);
+		 			//进行二次认证校验
+		 			twoFactorAuth = authenticationProvider.doTwoFactorAuthenticate(credential , user);
+
+		 			if(twoFactorAuth != null) {
+		 				logger.debug("twoFactorAuth success .");
+			 			//设置正常状态
+			 			principal.clearTwoFactor();
+			 			//重新设置令牌参数
+			 			sessionManager.create(sessionId, session);
+			 			sessionManager.removeTwoFactor(sessionId);
+			 			AuthorizationUtils.setAuthentication(session.getAuthentication());
+			 			authenticationRealm.insertLoginHistory(user, 
+			 					loginType, 
+				                "", 
+				                "xe00000004",
+				                WebConstants.LOGIN_RESULT.SUCCESS);
+			 			return session.getAuthentication();
+		 			}else {
+		 				logger.debug("twoFactorAuth fail .");
+		 			}
+		 		}else {//无会话
+		 			logger.debug("Session is timeout , sessionId [{}]" , sessionId);
+		 		}
+	 		}else {//验证失效
+	 			logger.debug("jwt token is not validate .");
+	 		}
+        }catch(Exception e) {
+        	logger.error("Exception !",e);
+ 		}
+        
+        return  null;
+    }
+    
+}

+ 90 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorEmailAuthenticationProvider.java

@@ -0,0 +1,90 @@
+
+ 
+
+package org.dromara.maxkey.authn.provider.twofactor.impl;
+
+import org.dromara.maxkey.authn.LoginCredential;
+import org.dromara.maxkey.authn.provider.AbstractAuthenticationProvider;
+import org.dromara.maxkey.entity.idm.UserInfo;
+import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn;
+import org.dromara.maxkey.password.onetimepwd.MailOtpAuthnService;
+import org.dromara.maxkey.web.WebConstants;
+import org.dromara.maxkey.web.WebContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+
+
+/**
+ * TwoFactor Authentication provider.二次认证邮件认证提供者
+ * 
+ * @author Crystal.Sea
+ *
+ */
+public class TwoFactorEmailAuthenticationProvider extends AbstractAuthenticationProvider {
+    private static final Logger logger = LoggerFactory.getLogger(TwoFactorEmailAuthenticationProvider.class);
+
+    MailOtpAuthnService mailOtpAuthnService;
+    
+    public String getProviderName() {
+        return "twoFactorEmail" + PROVIDER_SUFFIX;
+    }
+ 
+    public TwoFactorEmailAuthenticationProvider(MailOtpAuthnService mailOtpAuthnService) {
+		this.mailOtpAuthnService = mailOtpAuthnService;
+	}
+
+    @Override
+	public Authentication doAuthenticate(LoginCredential credential) {
+    	return null;
+    }
+    
+    @Override
+	public Authentication doTwoFactorAuthenticate(LoginCredential credential,UserInfo user) {
+		UsernamePasswordAuthenticationToken authenticationToken = null;
+		logger.debug("loginCredential {}" , credential);
+        try {
+	        //短信验证码校验
+	        matches(credential.getOtpCaptcha(),user);
+	        
+	        authenticationToken = new UsernamePasswordAuthenticationToken(credential.getUsername(),"email");
+	        
+        } catch (AuthenticationException e) {
+            logger.error("Failed to authenticate user {} via {}: {}",credential.getPrincipal(),
+                                    getProviderName(),
+                                    e.getMessage() );
+            WebContext.setAttribute(
+                    WebConstants.LOGIN_ERROR_SESSION_MESSAGE, e.getMessage());
+        } catch (Exception e) {
+            logger.error("Login error Unexpected exception in {} authentication:\n{}" ,
+                            getProviderName(), e.getMessage());
+        }
+       
+        return  authenticationToken;
+    }
+    
+    
+    /**
+     * mobile validate.手机验证码校验
+     * 
+     * @param otpCaptcha String
+     * @param authType   String
+     * @param userInfo   UserInfo
+     */
+    protected void matches(String captcha, UserInfo userInfo) {
+    	// for mobile password
+        UserInfo validUserInfo = new UserInfo();
+        validUserInfo.setUsername(userInfo.getUsername());
+        validUserInfo.setId(userInfo.getId());
+        AbstractOtpAuthn smsOtpAuthn = mailOtpAuthnService.getMailOtpAuthn(userInfo.getInstId());
+        if (captcha == null || !smsOtpAuthn.validate(validUserInfo, captcha)) {
+            String message = WebContext.getI18nValue("login.error.captcha");
+            logger.debug("login captcha valid error.");
+            throw new BadCredentialsException(message);
+        }
+    }
+  
+}

+ 88 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorMobileAuthenticationProvider.java

@@ -0,0 +1,88 @@
+
+ 
+
+package org.dromara.maxkey.authn.provider.twofactor.impl;
+
+import org.dromara.maxkey.authn.LoginCredential;
+import org.dromara.maxkey.authn.provider.AbstractAuthenticationProvider;
+import org.dromara.maxkey.entity.idm.UserInfo;
+import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn;
+import org.dromara.maxkey.password.sms.SmsOtpAuthnService;
+import org.dromara.maxkey.web.WebConstants;
+import org.dromara.maxkey.web.WebContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+
+
+/**
+ * TwoFactorMobile Authentication provider.二次认证手机认证提供者
+ * 
+ * @author Crystal.Sea
+ *
+ */
+public class TwoFactorMobileAuthenticationProvider extends AbstractAuthenticationProvider {
+    private static final Logger logger = LoggerFactory.getLogger(TwoFactorMobileAuthenticationProvider.class);
+
+    SmsOtpAuthnService smsOtpAuthnService;
+    
+    public String getProviderName() {
+        return "twoFactorMobile" + PROVIDER_SUFFIX;
+    }
+ 
+    public TwoFactorMobileAuthenticationProvider(SmsOtpAuthnService smsOtpAuthnService) {
+		this.smsOtpAuthnService = smsOtpAuthnService;
+	}
+
+    @Override
+	public Authentication doAuthenticate(LoginCredential credential) {
+    	return null;
+    }
+    
+    @Override
+	public Authentication doTwoFactorAuthenticate(LoginCredential credential,UserInfo user) {
+		UsernamePasswordAuthenticationToken authenticationToken = null;
+		logger.debug("loginCredential {}" , credential);
+        try {
+	        //短信验证码校验
+	        matches(credential.getOtpCaptcha(),user);
+	        
+	        authenticationToken = new UsernamePasswordAuthenticationToken(credential.getUsername(),"mobile");
+	    
+        } catch (AuthenticationException e) {
+            logger.error("Failed to authenticate user {} via {}: {}",credential.getPrincipal(),
+                                    getProviderName(),
+                                    e.getMessage() );
+            WebContext.setAttribute(WebConstants.LOGIN_ERROR_SESSION_MESSAGE, e.getMessage());
+        } catch (Exception e) {
+            logger.error("Login error Unexpected exception in {} authentication:\n{}" ,getProviderName(), e.getMessage());
+        }
+       
+        return  authenticationToken;
+    }
+    
+    
+    /**
+     * mobile validate.手机验证码校验
+     * 
+     * @param otpCaptcha String
+     * @param authType   String
+     * @param userInfo   UserInfo
+     */
+    protected void matches(String captcha, UserInfo userInfo) {
+    	// for mobile password
+        UserInfo validUserInfo = new UserInfo();
+        validUserInfo.setUsername(userInfo.getUsername());
+        validUserInfo.setId(userInfo.getId());
+        AbstractOtpAuthn smsOtpAuthn = smsOtpAuthnService.getByInstId(userInfo.getInstId());
+        if (captcha == null || !smsOtpAuthn.validate(validUserInfo, captcha)) {
+            String message = WebContext.getI18nValue("login.error.captcha");
+            logger.debug("login captcha valid error.");
+            throw new BadCredentialsException(message);
+        }
+    }
+  
+}

+ 84 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/provider/twofactor/impl/TwoFactorTotpAuthenticationProvider.java

@@ -0,0 +1,84 @@
+
+
+package org.dromara.maxkey.authn.provider.twofactor.impl;
+
+import org.dromara.maxkey.authn.LoginCredential;
+import org.dromara.maxkey.authn.provider.AbstractAuthenticationProvider;
+import org.dromara.maxkey.authn.realm.AbstractAuthenticationRealm;
+import org.dromara.maxkey.entity.idm.UserInfo;
+import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn;
+import org.dromara.maxkey.web.WebConstants;
+import org.dromara.maxkey.web.WebContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+
+
+/**
+ * TwoFactorTotp Authentication provider.二次认证TOTP认证提供者
+ * 
+ * @author Crystal.Sea
+ *
+ */
+public class TwoFactorTotpAuthenticationProvider extends AbstractAuthenticationProvider {
+    private static final Logger logger = LoggerFactory.getLogger(TwoFactorTotpAuthenticationProvider.class);
+    
+    public String getProviderName() {
+        return "twoFactorTotp" + PROVIDER_SUFFIX;
+    }
+ 
+    public TwoFactorTotpAuthenticationProvider(AbstractAuthenticationRealm authenticationRealm,AbstractOtpAuthn tfaOtpAuthn) {
+    	this.authenticationRealm = authenticationRealm;
+		this.tfaOtpAuthn = tfaOtpAuthn;
+	}
+
+    @Override
+	public Authentication doAuthenticate(LoginCredential credential) {
+    	return null;
+    }
+    
+    @Override
+	public Authentication doTwoFactorAuthenticate(LoginCredential credential,UserInfo user) {
+		UsernamePasswordAuthenticationToken authenticationToken = null;
+		logger.debug("loginCredential {}" , credential);
+        try {
+	        //验证码校验
+    		UserInfo userTotp = authenticationRealm.loadUserInfoById(user.getId());
+    		
+	        matches(credential.getOtpCaptcha(),userTotp.getSharedSecret());
+	        
+	        authenticationToken = new UsernamePasswordAuthenticationToken(credential.getUsername(),"TOTP");
+	        
+        } catch (AuthenticationException e) {
+            logger.error("Failed to authenticate user {} via {}: {}",credential.getPrincipal(),
+                                    getProviderName(),
+                                    e.getMessage() );
+            WebContext.setAttribute(WebConstants.LOGIN_ERROR_SESSION_MESSAGE, e.getMessage());
+        } catch (Exception e) {
+            logger.error("Login error Unexpected exception in {} authentication:\n{}" , getProviderName(), e.getMessage());
+        }
+       
+        return  authenticationToken;
+    }
+    
+    
+    /**
+     * 双因素验证.
+     * 
+     * @param otpCaptcha String
+     * @param authType   String
+     * @param userInfo   UserInfo
+     */
+    protected void matches(String captcha, String sharedSecret) {
+        // for one time password 2 factor
+        if (captcha == null || !tfaOtpAuthn.validate(sharedSecret, captcha)) {
+            String message = WebContext.getI18nValue("login.error.captcha");
+            logger.debug("login captcha valid error.");
+            throw new BadCredentialsException(message);
+        }
+    }
+  
+}

+ 4 - 0
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/authn/realm/AbstractAuthenticationRealm.java

@@ -85,6 +85,10 @@ public abstract class AbstractAuthenticationRealm {
     public UserInfo loadUserInfo(String username, String password) {
         return loginService.find(username, password);
     }
+    
+    public UserInfo loadUserInfoById(String userId) {
+        return loginService.findById(userId);
+    }
 
     public abstract boolean passwordMatches(UserInfo userInfo, String password);
     

+ 58 - 1
maxkey-authentications/maxkey-authentication-provider/src/main/java/org/dromara/maxkey/autoconfigure/AuthnProviderAutoConfiguration.java

@@ -21,17 +21,27 @@ import org.dromara.maxkey.authn.jwt.AuthTokenService;
 import org.dromara.maxkey.authn.provider.AbstractAuthenticationProvider;
 import org.dromara.maxkey.authn.provider.AuthenticationProviderFactory;
 import org.dromara.maxkey.authn.provider.impl.*;
+import org.dromara.maxkey.authn.provider.twofactor.TwoFactorAuthenticationProvider;
+import org.dromara.maxkey.authn.provider.twofactor.impl.TwoFactorEmailAuthenticationProvider;
+import org.dromara.maxkey.authn.provider.twofactor.impl.TwoFactorMobileAuthenticationProvider;
+import org.dromara.maxkey.authn.provider.twofactor.impl.TwoFactorTotpAuthenticationProvider;
 import org.dromara.maxkey.authn.realm.AbstractAuthenticationRealm;
 import org.dromara.maxkey.authn.session.SessionManager;
 import org.dromara.maxkey.authn.support.rememberme.AbstractRemeberMeManager;
 import org.dromara.maxkey.authn.support.rememberme.JdbcRemeberMeManager;
 import org.dromara.maxkey.configuration.ApplicationConfig;
+import org.dromara.maxkey.constants.ConstsTwoFactor;
+import org.dromara.maxkey.ip2location.IpLocationParser;
+import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn;
+import org.dromara.maxkey.password.onetimepwd.MailOtpAuthnService;
 import org.dromara.maxkey.password.sms.SmsOtpAuthnService;
 import org.dromara.maxkey.persistence.service.CnfPasswordPolicyService;
+import org.dromara.maxkey.persistence.service.LoginService;
 import org.dromara.maxkey.persistence.service.PasswordPolicyValidatorService;
 import org.dromara.maxkey.persistence.service.impl.PasswordPolicyValidatorServiceImpl;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.context.MessageSource;
@@ -49,7 +59,8 @@ public class AuthnProviderAutoConfiguration {
     		MobileAuthenticationProvider mobileAuthenticationProvider,
     		TrustedAuthenticationProvider trustedAuthenticationProvider,
 			ScanCodeAuthenticationProvider scanCodeAuthenticationProvider,
-			AppAuthenticationProvider appAuthenticationProvider
+			AppAuthenticationProvider appAuthenticationProvider,
+			TwoFactorAuthenticationProvider twoFactorAuthenticationProvider
     		) {
     	AuthenticationProviderFactory authenticationProvider = new AuthenticationProviderFactory();
     	authenticationProvider.addAuthenticationProvider(normalAuthenticationProvider);
@@ -58,6 +69,9 @@ public class AuthnProviderAutoConfiguration {
     	authenticationProvider.addAuthenticationProvider(scanCodeAuthenticationProvider);
     	authenticationProvider.addAuthenticationProvider(appAuthenticationProvider);
 
+    	//二次认证
+    	authenticationProvider.addAuthenticationProvider(twoFactorAuthenticationProvider);
+    	
     	return authenticationProvider;
     }
 
@@ -155,5 +169,48 @@ public class AuthnProviderAutoConfiguration {
         return new  JdbcRemeberMeManager(
         		jdbcTemplate,applicationConfig,authTokenService,validity);
     }
+    
+    @Bean
+    TwoFactorAuthenticationProvider twoFactorAuthenticationProvider(
+    		AbstractAuthenticationRealm authenticationRealm,
+            SessionManager sessionManager,
+            LoginService loginService,
+            AuthTokenService authTokenService,
+            IpLocationParser ipLocationParser,
+            TwoFactorTotpAuthenticationProvider twoFactorTotpAuthenticationProvider,
+            TwoFactorMobileAuthenticationProvider twoFactorMobileAuthenticationProvider,
+            TwoFactorEmailAuthenticationProvider twoFactorEmailAuthenticationProvider) {
+    	_logger.debug("init TwoFactor authentication Provider .");
+    	TwoFactorAuthenticationProvider  twoFactorProvider =new TwoFactorAuthenticationProvider(
+        		authenticationRealm,
+        		sessionManager,
+        		loginService,
+        		authTokenService
+        	);
+    	
+    	twoFactorProvider.addProvider(ConstsTwoFactor.TOTP, twoFactorTotpAuthenticationProvider);
+    	twoFactorProvider.addProvider(ConstsTwoFactor.EMAIL, twoFactorEmailAuthenticationProvider);
+    	twoFactorProvider.addProvider(ConstsTwoFactor.SMS, twoFactorMobileAuthenticationProvider);
+    	return twoFactorProvider;
+    }
+    
+    @Bean
+    TwoFactorTotpAuthenticationProvider twoFactorTotpAuthenticationProvider(@Qualifier("tfaOtpAuthn") AbstractOtpAuthn tfaOtpAuthn,
+    		AbstractAuthenticationRealm authenticationRealm) {
+    	_logger.debug("init TwoFactor authentication Provider .");
+    	return new TwoFactorTotpAuthenticationProvider(authenticationRealm,tfaOtpAuthn);
+    }
+    
+    @Bean
+    TwoFactorMobileAuthenticationProvider twoFactorMobileAuthenticationProvider(SmsOtpAuthnService smsOtpAuthnService) {
+    	_logger.debug("init TwoFactor Mobile authentication Provider .");
+    	return new TwoFactorMobileAuthenticationProvider(smsOtpAuthnService);
+    }
+    
+    @Bean
+    TwoFactorEmailAuthenticationProvider twoFactorEmailAuthenticationProvider(MailOtpAuthnService mailOtpAuthnService) {
+    	_logger.debug("init TwoFactor Email authentication Provider .");
+    	return new TwoFactorEmailAuthenticationProvider(mailOtpAuthnService);
+    }
 
 }

+ 30 - 0
maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsJwt.java

@@ -0,0 +1,30 @@
+
+ 
+package org.dromara.maxkey.constants;
+
+/**
+ * Jwt.
+ * @author Crystal.Sea
+ *
+ */
+public final class ConstsJwt {
+
+    public static final String ACCESS_TOKEN     = "access_token";
+    
+    public static final String REFRESH_TOKEN    = "refresh_token";
+    
+    public static final String EXPIRES_IN       = "expired";
+    
+    public static final String INST_ID          = "instId";
+    
+    public static final String USER_ID          = "userId";
+    
+    public static final String STYLE            = "style";
+    
+    public static final String TWO_FACTOR       = "twoFactor";
+    
+    public static final String KID              = "kid";
+    
+    public static final String LOCALE           = "locale";
+    
+}

+ 15 - 0
maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsLoginType.java

@@ -43,4 +43,19 @@ public class ConstsLoginType {
 
     public static final String HTTPHEADER 		= "HttpHeader";
 
+    
+    public static final class TwoFactor{
+    	/**
+    	 * 1=TOTP(动态验证码)  
+    	 */
+    	public static final String TWO_FACTOR_TOTP 		= "TwoFactorTotp";
+    	/**
+    	 * 2=邮箱验证码
+    	 */
+    	public static final String TWO_FACTOR_EMAIL 	= "TwoFactorEmail";
+    	/**
+    	 * 3=手机短信
+    	 */
+    	public static final String TWO_FACTOR_MOBILE 	= "TwoFactorMobile";
+    }
 }

+ 27 - 0
maxkey-commons/maxkey-common/src/main/java/org/dromara/maxkey/constants/ConstsTwoFactor.java

@@ -0,0 +1,27 @@
+
+
+package org.dromara.maxkey.constants;
+
+/**
+ * 二次认证验证码
+ */
+public class ConstsTwoFactor {
+
+	/**
+	 * 无
+	 */
+    public static final int NONE 				= 0;
+    
+    /**
+     * 动态令牌TOTP
+     */
+    public static final int TOTP 				= 1;
+    /**
+     * 邮件验证码
+     */
+    public static final int EMAIL 				= 2;
+    /**
+     * 短信验证码
+     */
+    public static final int SMS 				= 3;
+}

+ 2 - 0
maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/LoginService.java

@@ -27,6 +27,8 @@ public interface LoginService {
     
     public UserInfo find(String username, String password);
 
+    public UserInfo findById(String userId);
+    
     public List<UserInfo> findByUsername(String username, String password);
 
     public List<UserInfo> findByUsernameOrMobile(String username, String password);

+ 12 - 0
maxkey-persistence/src/main/java/org/dromara/maxkey/persistence/service/impl/LoginServiceImpl.java

@@ -58,6 +58,8 @@ public class LoginServiceImpl  implements LoginService{
     private static final String GROUPS_SELECT_STATEMENT = "select distinct g.id,g.groupcode,g.groupname from mxk_userinfo u,mxk_groups g,mxk_group_member gm where u.id = ?  and u.id=gm.memberid and gm.groupid=g.id ";
 
     private static final String DEFAULT_USERINFO_SELECT_STATEMENT = "select * from  mxk_userinfo where username = ? ";
+    
+    private static final String DEFAULT_USERINFO_SELECT_STATEMENT_BY_ID = "select * from  mxk_userinfo where id = ? ";
 
     private static final String DEFAULT_USERINFO_SELECT_STATEMENT_USERNAME_MOBILE = "select * from  mxk_userinfo where (username = ? or mobile = ?)";
 
@@ -491,6 +493,16 @@ public class LoginServiceImpl  implements LoginService{
             return userInfo;
         }
     }
+
+	@Override
+	public UserInfo findById(String userId) {
+		List<UserInfo> listUserInfo = jdbcTemplate.query(
+				DEFAULT_USERINFO_SELECT_STATEMENT_BY_ID,
+    			new UserInfoRowMapper(),
+    			userId
+    		);
+		return (CollectionUtils.isNotEmpty(listUserInfo) ? listUserInfo.get(0) : null);
+	}
 }
 
 

+ 2 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/AbstractOtpAuthn.java

@@ -69,6 +69,8 @@ public abstract class AbstractOtpAuthn {
     public abstract boolean produce(UserInfo userInfo);
 
     public abstract boolean validate(UserInfo userInfo, String token);
+    
+    public abstract boolean validate(String sharedSecret, String token);
 
     protected String defaultProduce(UserInfo userInfo) {
         return genToken(userInfo);

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/CapOtpAuthn.java

@@ -48,4 +48,10 @@ public class CapOtpAuthn extends AbstractOtpAuthn {
         return false;
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/CounterBasedOtpAuthn.java

@@ -72,4 +72,10 @@ public class CounterBasedOtpAuthn extends AbstractOtpAuthn {
         return false;
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/HotpOtpAuthn.java

@@ -95,4 +95,10 @@ public class HotpOtpAuthn extends AbstractOtpAuthn {
         this.truncation = truncation;
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/MailOtpAuthn.java

@@ -121,6 +121,12 @@ public class MailOtpAuthn extends AbstractOtpAuthn {
     public void setMessageTemplate(String messageTemplate) {
         this.messageTemplate = messageTemplate;
     }
+
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
     
 
 }

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/MobileOtpAuthn.java

@@ -38,4 +38,10 @@ public class MobileOtpAuthn extends AbstractOtpAuthn {
         return false;
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

+ 6 - 0
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/RsaOtpAuthn.java

@@ -48,4 +48,10 @@ public class RsaOtpAuthn extends AbstractOtpAuthn {
         return false;
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

+ 8 - 4
maxkey-starter/maxkey-starter-otp/src/main/java/org/dromara/maxkey/password/onetimepwd/impl/TimeBasedOtpAuthn.java

@@ -51,9 +51,14 @@ public class TimeBasedOtpAuthn extends AbstractOtpAuthn {
 
     @Override
     public boolean validate(UserInfo userInfo, String token) {
-        _logger.debug("utcTime : {}" , dateFormat.format(new Date()));
+       return validate(userInfo.getSharedSecret() , token);
+    }
+
+	@Override
+	public boolean validate(String secret, String token) {
+		_logger.debug("utcTime : {}" , dateFormat.format(new Date()));
         long currentTimeSeconds = System.currentTimeMillis() / 1000;
-        String sharedSecret = PasswordReciprocal.getInstance().decoder(userInfo.getSharedSecret());
+        String sharedSecret = PasswordReciprocal.getInstance().decoder(secret);
         byte[] byteSharedSecret = Base32Utils.decode(sharedSecret);
         String hexSharedSecret = Hex.encodeHexString(byteSharedSecret);
         String timeBasedToken = "";
@@ -79,7 +84,6 @@ public class TimeBasedOtpAuthn extends AbstractOtpAuthn {
             return true;
         }
         return false;
-
-    }
+	}
 
 }

+ 6 - 0
maxkey-starter/maxkey-starter-sms/src/main/java/org/dromara/maxkey/password/sms/SmsOtpAuthn.java

@@ -56,4 +56,10 @@ public class SmsOtpAuthn extends AbstractOtpAuthn {
         
     }
 
+	@Override
+	public boolean validate(String sharedSecret, String token) {
+		// TODO Auto-generated method stub
+		return false;
+	}
+
 }

Plik diff jest za duży
+ 230 - 2
maxkey-web-frontend/maxkey-web-app/package-lock.json


+ 8 - 3
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts

@@ -291,9 +291,14 @@ export class UserLoginComponent implements OnInit, OnDestroy {
         } else {
           // 清空路由复用信息
           this.reuseTabService.clear();
-          // 设置用户Token信息
-          this.authnService.auth(res.data);
-          this.authnService.navigate({});
+          if (res.data.twoFactor === '0') {
+            // 设置用户Token信息
+            this.authnService.auth(res.data);
+            this.authnService.navigate({});
+          } else {
+            localStorage.setItem(CONSTS.TWO_FACTOR_DATA, JSON.stringify(res.data));
+            this.router.navigateByUrl('/passport/tfa');
+          }
         }
         this.cdr.detectChanges();
       });

+ 6 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/passport-routing.module.ts

@@ -26,6 +26,7 @@ import { UserLoginComponent } from './login/login.component';
 import { LogoutComponent } from './logout.component';
 import { UserRegisterResultComponent } from './register-result/register-result.component';
 import { UserRegisterComponent } from './register/register.component';
+import { TfaComponent } from './tfa/tfa.component';
 
 const routes: Routes = [
   // passport
@@ -39,6 +40,11 @@ const routes: Routes = [
         data: { title: '登录', titleI18n: 'app.login.login' }
       },
       {
+        path: 'tfa',
+        component: TfaComponent,
+        data: { title: '登录二次认证', titleI18n: 'app.login.login' }
+      },
+      {
         path: 'register',
         component: UserRegisterComponent,
         data: { title: '注册', titleI18n: 'app.register.register' }

+ 12 - 2
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/passport.module.ts

@@ -17,16 +17,26 @@
 import { NgModule } from '@angular/core';
 import { SharedModule } from '@shared';
 import { NzStepsModule } from 'ng-zorro-antd/steps';
+
 import { CallbackComponent } from './callback.component';
-import { SocialsProviderBindUserComponent } from './socials-provider-bind-user/socials-provider-bind-user.component';
 import { ForgotComponent } from './forgot/forgot.component';
 import { UserLockComponent } from './lock/lock.component';
 import { UserLoginComponent } from './login/login.component';
 import { PassportRoutingModule } from './passport-routing.module';
 import { UserRegisterResultComponent } from './register-result/register-result.component';
 import { UserRegisterComponent } from './register/register.component';
+import { SocialsProviderBindUserComponent } from './socials-provider-bind-user/socials-provider-bind-user.component';
+import { TfaComponent } from './tfa/tfa.component';
 
-const COMPONENTS = [SocialsProviderBindUserComponent,UserLoginComponent, UserRegisterResultComponent, UserRegisterComponent, UserLockComponent, CallbackComponent];
+const COMPONENTS = [
+  TfaComponent,
+  SocialsProviderBindUserComponent,
+  UserLoginComponent,
+  UserRegisterResultComponent,
+  UserRegisterComponent,
+  UserLockComponent,
+  CallbackComponent
+];
 
 @NgModule({
   imports: [SharedModule, PassportRoutingModule, NzStepsModule],

+ 101 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.html

@@ -0,0 +1,101 @@
+<!--二次认证-->
+<form nz-form [formGroup]="form" (ngSubmit)="submit()" role="form">
+  <div nz-row>
+    <nz-alert *ngIf="error" style="width: 100%" [nzType]="'error'" [nzMessage]="error" [nzShowIcon]="true" class="mb-lg"></nz-alert>
+    <nz-radio-group nzSize="large" style="margin-bottom: 8px; width: 100%">
+      <label nz-radio-button nzValue="normal" style="width: 368px; text-align: center">
+        <i nz-icon nzType="safety" nzTheme="outline"></i>
+        {{ 'mxk.login.twoFactor' | i18n }}
+      </label>
+    </nz-radio-group>
+    <nz-form-item *ngIf="twoFactorType == '1'" style="width: 100%">
+      <nz-form-control [nzErrorTip]="'' | i18n">
+        <nz-input-group nzSize="large" nzPrefixIcon="lock" nzSearch>
+          <input nz-input formControlName="otpCaptcha" placeholder="{{ 'mxk.login.text.captcha' | i18n }}" />
+        </nz-input-group>
+      </nz-form-control>
+    </nz-form-item>
+    <nz-form-item *ngIf="twoFactorType == '2'" style="width: 100%">
+      <nz-form-control [nzErrorTip]="">
+        <nz-input-group nzSize="large" nzPrefixIcon="user">
+          <input nz-input formControlName="twoFactorEmail" placeholder="{{ 'mxk.login.text.mobile' | i18n }}" />
+        </nz-input-group>
+        <ng-template #mobileErrorTip let-i>
+          <ng-container *ngIf="i.errors.required">
+            {{ 'validation.phone-number.required' | i18n }}
+          </ng-container>
+          <ng-container *ngIf="i.errors.pattern">
+            {{ 'validation.phone-number.wrong-format' | i18n }}
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+    <nz-form-item *ngIf="twoFactorType == '2'" style="width: 100%">
+      <nz-form-control [nzErrorTip]="'' | i18n">
+        <nz-input-group nzSize="large" nzPrefixIcon="mail" nzSearch [nzAddOnAfter]="suffixSendOtpCodeButton">
+          <input nz-input formControlName="otpCaptcha" placeholder="{{ 'mxk.login.text.captcha' | i18n }}" />
+        </nz-input-group>
+        <ng-template #suffixSendOtpCodeButton>
+          <button
+            type="button"
+            nz-button
+            nzSize="large"
+            (click)="sendTwoFactorOtpCode()"
+            [disabled]="count > 0"
+            nzBlock
+            [nzLoading]="loading"
+          >
+            {{ count ? count + 's' : ('app.register.get-verification-code' | i18n) }}
+          </button>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <nz-form-item *ngIf="twoFactorType == '3'" style="width: 100%">
+      <nz-form-control [nzErrorTip]="">
+        <nz-input-group nzSize="large" nzPrefixIcon="user">
+          <input nz-input formControlName="twoFactorMobile" placeholder="{{ 'mxk.login.text.mobile' | i18n }}" />
+        </nz-input-group>
+        <ng-template #mobileErrorTip let-i>
+          <ng-container *ngIf="i.errors.required">
+            {{ 'validation.phone-number.required' | i18n }}
+          </ng-container>
+          <ng-container *ngIf="i.errors.pattern">
+            {{ 'validation.phone-number.wrong-format' | i18n }}
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+    <nz-form-item *ngIf="twoFactorType == '3'" style="width: 100%">
+      <nz-form-control [nzErrorTip]="'' | i18n">
+        <nz-input-group nzSize="large" nzPrefixIcon="mail" nzSearch [nzAddOnAfter]="suffixSendOtpCodeButton">
+          <input nz-input formControlName="otpCaptcha" placeholder="{{ 'mxk.login.text.captcha' | i18n }}" />
+        </nz-input-group>
+        <ng-template #suffixSendOtpCodeButton>
+          <button
+            type="button"
+            nz-button
+            nzSize="large"
+            (click)="sendTwoFactorOtpCode()"
+            [disabled]="count > 0"
+            nzBlock
+            [nzLoading]="loading"
+          >
+            {{ count ? count + 's' : ('app.register.get-verification-code' | i18n) }}
+          </button>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+  </div>
+  <nz-form-item>
+    <nz-col [nzSpan]="12"></nz-col>
+    <nz-col [nzSpan]="12" class="text-right">
+      <a class="forgot" routerLink="/passport/login">{{ 'mxk.forgot.login' | i18n }}</a>
+    </nz-col>
+  </nz-form-item>
+  <nz-form-item>
+    <button nz-button type="submit" nzType="primary" nzSize="large" [nzLoading]="loading" nzBlock>
+      {{ 'app.login.login' | i18n }}
+    </button>
+  </nz-form-item>
+</form>

+ 75 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.less

@@ -0,0 +1,75 @@
+@import '@delon/theme/index';
+
+:host {
+  display: block;
+  width: 368px;
+  margin: 0 auto;
+
+
+  ::ng-deep {
+    .ant-tabs .ant-tabs-bar {
+      margin-bottom: 24px;
+      text-align: center;
+      border-bottom: 0;
+    }
+
+    .ant-tabs-tab {
+      font-size: 16px;
+      line-height: 24px;
+    }
+
+    .ant-input-affix-wrapper .ant-input:not(:first-child) {
+      padding-left: 4px;
+    }
+
+    .login-tab {
+      color: #000;
+      background: unset;
+      border-color: unset;
+      border-right-color: unset;
+    }
+
+    .icon {
+      margin-left: 16px;
+      color: rgb(0 0 0 / 20%);
+      font-size: 24px;
+      vertical-align: middle;
+      cursor: pointer;
+      transition: color 0.3s;
+
+      &:hover {
+        color: @primary-color;
+      }
+    }
+
+    .other {
+      margin-top: 24px;
+      line-height: 22px;
+      text-align: left;
+
+      nz-tooltip {
+        vertical-align: middle;
+      }
+
+      .register {
+        float: right;
+      }
+    }
+  }
+}
+
+[data-theme='dark'] {
+  :host ::ng-deep {
+    .icon {
+      color: rgb(255 255 255 / 20%);
+
+      &:hover {
+        color: #fff;
+      }
+    }
+  }
+}
+
+input {
+  font-weight: bold;
+}

+ 23 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TfaComponent } from './tfa.component';
+
+describe('TfaComponent', () => {
+  let component: TfaComponent;
+  let fixture: ComponentFixture<TfaComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [TfaComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(TfaComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 212 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/tfa/tfa.component.ts

@@ -0,0 +1,212 @@
+/*
+ * Copyright [2025] [MaxKey of copyright http://www.maxkey.top]
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  Inject,
+  inject,
+  OnInit,
+  OnDestroy,
+  AfterViewInit,
+  Optional
+} from '@angular/core';
+import { AbstractControl, ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
+import { Router, ActivatedRoute, RouterLink } from '@angular/router';
+import { ReuseTabService } from '@delon/abc/reuse-tab';
+import { SettingsService, _HttpClient, I18nPipe } from '@delon/theme';
+import { environment } from '@env/environment';
+import { NzAlertModule } from 'ng-zorro-antd/alert';
+import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
+import { NzSafeAny } from 'ng-zorro-antd/core/types';
+import { NzFormModule } from 'ng-zorro-antd/form';
+import { NzIconModule } from 'ng-zorro-antd/icon';
+import { NzInputModule } from 'ng-zorro-antd/input';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { NzRadioModule } from 'ng-zorro-antd/radio';
+import { NzTabChangeEvent, NzTabsModule } from 'ng-zorro-antd/tabs';
+import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
+import { finalize } from 'rxjs/operators';
+
+import { AuthnService } from '../../../service/authn.service';
+import { ImageCaptchaService } from '../../../service/image-captcha.service';
+import { CONSTS } from '../../../shared/consts';
+
+@Component({
+  selector: 'app-tfa',
+  templateUrl: './tfa.component.html',
+  styleUrls: ['./tfa.component.less'],
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class TfaComponent implements OnInit {
+  form: FormGroup;
+  error = '';
+  secretKey = '';
+  secretPublicKey = '';
+  captchaType = '';
+  twoFactorType = '0';
+  twoFactorJwt = '';
+  isFirstPasswordModify = 'N';
+  state = '';
+  defualtRedirectUri = '';
+  count = 0;
+  interval$: any;
+  loading = false;
+  constructor(
+    fb: FormBuilder,
+    private router: Router,
+    private settingsService: SettingsService,
+    private authnService: AuthnService,
+    private imageCaptchaService: ImageCaptchaService,
+    private route: ActivatedRoute,
+    private msg: NzMessageService,
+    @Optional()
+    @Inject(ReuseTabService)
+    private reuseTabService: ReuseTabService,
+    private cdr: ChangeDetectorRef
+  ) {
+    this.form = fb.group({
+      userName: [null, [Validators.required]],
+      password: [null, [Validators.required]],
+      captcha: [null, [Validators.required]],
+      mobile: [null, [Validators.required, Validators.pattern(/^1\d{10}$/)]],
+      twoFactorMobile: [null, [Validators.required, Validators.pattern(/^1\d{10}$/)]],
+      twoFactorEmail: [null, [Validators.required, Validators.pattern(/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/)]],
+      otpCaptcha: [null, [Validators.required]],
+      remember: [false]
+    });
+  }
+
+  ngOnInit(): void {
+    this.authnService
+      .get({ remember_me: localStorage.getItem(CONSTS.REMEMBER) })
+      .pipe(
+        finalize(() => {
+          this.loading = false;
+          this.cdr.detectChanges();
+        })
+      )
+      .subscribe(res => {
+        this.loading = true;
+        if (res.code !== 0) {
+          this.error = res.msg;
+        } else {
+          this.state = res.data.state;
+          this.defualtRedirectUri = res.data.redirectUri;
+          this.captchaType = res.data.captcha;
+          this.secretKey = res.data.secretKey;
+          this.secretPublicKey = res.data.secretPublicKey;
+          this.isFirstPasswordModify = res.data.isFirstPasswordModify;
+        }
+      });
+    let twoFactorData = JSON.parse(localStorage.getItem(CONSTS.TWO_FACTOR_DATA) || '');
+    this.twoFactorType = twoFactorData.twoFactor;
+    this.twoFactorJwt = twoFactorData.token;
+    this.twoFactorMobile.setValue(twoFactorData.mobile);
+    this.twoFactorEmail.setValue(twoFactorData.email);
+  }
+
+  get userName(): AbstractControl {
+    return this.form.get('userName')!;
+  }
+  get password(): AbstractControl {
+    return this.form.get('password')!;
+  }
+  get mobile(): AbstractControl {
+    return this.form.get('mobile')!;
+  }
+  get captcha(): AbstractControl {
+    return this.form.get('captcha')!;
+  }
+
+  get otpCaptcha(): AbstractControl {
+    return this.form.get('otpCaptcha')!;
+  }
+
+  get remember(): AbstractControl {
+    return this.form.get('remember')!;
+  }
+
+  get twoFactorMobile(): AbstractControl {
+    return this.form.get('twoFactorMobile')!;
+  }
+
+  get twoFactorEmail(): AbstractControl {
+    return this.form.get('twoFactorEmail')!;
+  }
+
+  sendTwoFactorOtpCode(): void {
+    this.authnService.sendTwoFactorCode({ jwtToken: this.twoFactorJwt }).subscribe(res => {
+      if (res.code !== 0) {
+        this.msg.success(`发送失败`);
+      }
+    });
+    this.count = 59;
+    this.interval$ = setInterval(() => {
+      this.count -= 1;
+      if (this.count <= 0) {
+        clearInterval(this.interval$);
+      }
+      this.cdr.detectChanges();
+    }, 1000);
+  }
+
+  submit(): void {
+    this.error = '';
+
+    this.otpCaptcha.markAsDirty();
+    this.otpCaptcha.updateValueAndValidity();
+
+    localStorage.setItem(CONSTS.REMEMBER, this.form.get(CONSTS.REMEMBER)?.value);
+
+    this.loading = true;
+    this.cdr.detectChanges();
+    this.authnService
+      .login({
+        authType: 'twoFactor',
+        state: this.state,
+        jwtToken: this.twoFactorJwt,
+        otpCaptcha: this.otpCaptcha.value,
+        remeberMe: this.remember.value
+      })
+      .pipe(
+        finalize(() => {
+          this.loading = false;
+          this.cdr.detectChanges();
+        })
+      )
+      .subscribe(res => {
+        this.loading = true;
+        if (res.code !== 0) {
+          this.error = res.msg;
+        } else {
+          localStorage.removeItem(CONSTS.TWO_FACTOR_DATA);
+          // 清空路由复用信息
+          this.reuseTabService?.clear();
+          // 设置用户Token信息
+          this.authnService.auth(res.data);
+          this.authnService.navigate({
+            defualtRedirectUri: this.defualtRedirectUri,
+            isFirstPasswordModify: this.isFirstPasswordModify,
+            passwordSetType: res.data.passwordSetType
+          });
+        }
+        this.cdr.detectChanges();
+      });
+  }
+}

+ 4 - 0
maxkey-web-frontend/maxkey-web-app/src/app/service/authn.service.ts

@@ -61,6 +61,10 @@ export class AuthnService {
     return this.http.post('/login/signin?_allow_anonymous=true', authParam);
   }
 
+  sendTwoFactorCode(authParam: any) {
+    return this.http.post(`/login/sendTwoFactorCode?_allow_anonymous=true`, authParam);
+  }
+
   bindSocialsUser(authParam: any) {
     return this.http.post('/login/signin/bindusersocials?_allow_anonymous=true', authParam);
   }

+ 1 - 0
maxkey-web-frontend/maxkey-web-app/src/app/shared/consts.ts

@@ -22,6 +22,7 @@ export const CONSTS = {
     /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/gi,
   INST: 'inst',
   CONGRESS: 'congress',
+  TWO_FACTOR_DATA: 'two_factor_data',
   ONLINE_TICKET: 'online_ticket',
   REDIRECT_URI: 'redirect_uri',
   REMEMBER: 'remember_me',

+ 1 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json

@@ -15,6 +15,7 @@
 			"sign-in-with": "Sign in with",
 			"signup": "Sign up",
 			"login": "Login",
+      "twoFactor": "2-Factor Authentication",
 			"text.username": "Username",
 			"text.mobile": "Mobile Number",
 			"text.password": "Password",

+ 1 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json

@@ -15,6 +15,7 @@
 			"sign-in-with": "其他登录方式",
 			"signup": "用户注册",
 			"login": "登录",
+      "twoFactor": "二次身份认证",
 			"text.username": "用户名",
 			"text.mobile": "手机号码",
 			"text.password": "密码",

+ 1 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-TW.json

@@ -15,6 +15,7 @@
 			"sign-in-with": "其他登錄方式",
 			"signup": "用戶註冊",
 			"login": "登錄",
+      "twoFactor": "二次身份认证",
 			"text.username": "用戶名",
 			"text.mobile": "手機號碼",
 			"text.password": "密碼",

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików