mirror of https://github.com/halo-dev/halo
Support MFA-TOTP Auth (#745)
* add tfa utils * MFA check api completed * adminController add loginPreCheck api * halo-admin test * checkstyle * add unit test * reset MFApull/756/head
parent
b3dafa195a
commit
3a45b7a5ed
|
@ -72,6 +72,7 @@ ext {
|
|||
jsonVersion = "20190722"
|
||||
fastJsonVersion = "1.2.67"
|
||||
annotationsVersion = "3.0.1"
|
||||
zxingVersion = "3.4.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -121,6 +122,8 @@ dependencies {
|
|||
implementation "org.json:json:$jsonVersion"
|
||||
implementation "com.alibaba:fastjson:$fastJsonVersion"
|
||||
|
||||
implementation "com.google.zxing:core:$zxingVersion"
|
||||
|
||||
implementation "org.iq80.leveldb:leveldb:$levelDbVersion"
|
||||
runtimeOnly "com.h2database:h2:$h2Version"
|
||||
runtimeOnly "mysql:mysql-connector-java"
|
||||
|
|
|
@ -8,7 +8,10 @@ import run.halo.app.Application;
|
|||
import run.halo.app.cache.lock.CacheLock;
|
||||
import run.halo.app.model.annotation.DisableOnCondition;
|
||||
import run.halo.app.model.dto.EnvironmentDTO;
|
||||
import run.halo.app.model.dto.LoginPreCheckDTO;
|
||||
import run.halo.app.model.dto.StatisticDTO;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.model.params.LoginParam;
|
||||
import run.halo.app.model.params.ResetPasswordParam;
|
||||
import run.halo.app.model.properties.PrimaryProperties;
|
||||
|
@ -46,11 +49,19 @@ public class AdminController {
|
|||
return optionService.getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);
|
||||
}
|
||||
|
||||
@PostMapping("login/precheck")
|
||||
@ApiOperation("Login")
|
||||
@CacheLock(autoDelete = false, prefix = "login_precheck")
|
||||
public LoginPreCheckDTO authPreCheck(@RequestBody @Valid LoginParam loginParam) {
|
||||
final User user = adminService.authenticate(loginParam);
|
||||
return new LoginPreCheckDTO(MFAType.useMFA(user.getMfaType()));
|
||||
}
|
||||
|
||||
@PostMapping("login")
|
||||
@ApiOperation("Login")
|
||||
@CacheLock(autoDelete = false)
|
||||
@CacheLock(autoDelete = false, prefix = "login_auth")
|
||||
public AuthToken auth(@RequestBody @Valid LoginParam loginParam) {
|
||||
return adminService.authenticate(loginParam);
|
||||
return adminService.authCodeCheck(loginParam);
|
||||
}
|
||||
|
||||
@PostMapping("logout")
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
package run.halo.app.controller.admin.api;
|
||||
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.qrcode.QrCodeUtil;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import run.halo.app.cache.lock.CacheLock;
|
||||
import run.halo.app.exception.BadRequestException;
|
||||
import run.halo.app.model.annotation.DisableOnCondition;
|
||||
import run.halo.app.model.dto.UserDTO;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.model.params.MultiFactorAuthParam;
|
||||
import run.halo.app.model.params.PasswordParam;
|
||||
import run.halo.app.model.params.UserParam;
|
||||
import run.halo.app.model.support.BaseResponse;
|
||||
import run.halo.app.model.support.UpdateCheck;
|
||||
import run.halo.app.model.vo.MultiFactorAuthVO;
|
||||
import run.halo.app.service.UserService;
|
||||
import run.halo.app.utils.TwoFactorAuthUtils;
|
||||
import run.halo.app.utils.ValidationUtils;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
@ -57,4 +66,42 @@ public class UserController {
|
|||
userService.updatePassword(passwordParam.getOldPassword(), passwordParam.getNewPassword(), user.getId());
|
||||
return BaseResponse.ok("密码修改成功");
|
||||
}
|
||||
|
||||
@PutMapping("mfa/generate")
|
||||
@ApiOperation("Generate Multi-Factor Auth qr image")
|
||||
@DisableOnCondition
|
||||
public MultiFactorAuthVO generateMFAQrImage(@RequestBody MultiFactorAuthParam multiFactorAuthParam, User user) {
|
||||
if (MFAType.NONE == user.getMfaType()) {
|
||||
if (MFAType.TFA_TOTP == multiFactorAuthParam.getMfaType()) {
|
||||
String mfaKey = TwoFactorAuthUtils.generateTFAKey();
|
||||
String optAuthUrl = TwoFactorAuthUtils.generateOtpAuthUrl(user.getNickname(), mfaKey);
|
||||
String qrImageBase64 = "data:image/png;base64," +
|
||||
Base64.encode(QrCodeUtil.generatePng(optAuthUrl, 128, 128));
|
||||
return new MultiFactorAuthVO(qrImageBase64, optAuthUrl, mfaKey, MFAType.TFA_TOTP);
|
||||
} else {
|
||||
throw new BadRequestException("暂不支持的 MFA 认证的方式");
|
||||
}
|
||||
} else {
|
||||
throw new BadRequestException("MFA 认证已启用,无需重复操作");
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("mfa/update")
|
||||
@ApiOperation("Updates user's Multi Factor Auth")
|
||||
@CacheLock(autoDelete = false, prefix = "mfa")
|
||||
@DisableOnCondition
|
||||
public MultiFactorAuthVO updateMFAuth(@RequestBody @Valid MultiFactorAuthParam multiFactorAuthParam, User user) {
|
||||
if (StrUtil.isNotBlank(user.getMfaKey()) && MFAType.useMFA(multiFactorAuthParam.getMfaType())) {
|
||||
return new MultiFactorAuthVO(MFAType.TFA_TOTP);
|
||||
} else if (StrUtil.isBlank(user.getMfaKey()) && !MFAType.useMFA(multiFactorAuthParam.getMfaType())) {
|
||||
return new MultiFactorAuthVO(MFAType.NONE);
|
||||
} else {
|
||||
final String tfaKey = StrUtil.isNotBlank(user.getMfaKey()) ? user.getMfaKey() : multiFactorAuthParam.getMfaKey();
|
||||
TwoFactorAuthUtils.validateTFACode(tfaKey, multiFactorAuthParam.getAuthcode());
|
||||
}
|
||||
// update MFA key
|
||||
User updateUser = userService.updateMFA(multiFactorAuthParam.getMfaType(), multiFactorAuthParam.getMfaKey(), user.getId());
|
||||
|
||||
return new MultiFactorAuthVO(updateUser.getMfaType());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* User login env.
|
||||
*
|
||||
* @author Mu_Wooo
|
||||
* @version 1.0
|
||||
* @date 2020/3/31 2:39 下午
|
||||
* @project halo
|
||||
*/
|
||||
@Data
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
public class LoginPreCheckDTO {
|
||||
|
||||
private boolean needMFACode;
|
||||
|
||||
}
|
|
@ -5,6 +5,7 @@ import lombok.EqualsAndHashCode;
|
|||
import lombok.ToString;
|
||||
import run.halo.app.model.dto.base.OutputConverter;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
|
@ -31,6 +32,8 @@ public class UserDTO implements OutputConverter<UserDTO, User> {
|
|||
|
||||
private String description;
|
||||
|
||||
private MFAType mfaType;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
|
|
@ -3,6 +3,8 @@ package run.halo.app.model.entity;
|
|||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.utils.DateUtils;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
@ -69,6 +71,18 @@ public class User extends BaseEntity {
|
|||
@Temporal(TemporalType.TIMESTAMP)
|
||||
private Date expireTime;
|
||||
|
||||
/**
|
||||
* mfa type (current: tfa)
|
||||
*/
|
||||
@Column(name = "mfa_type", nullable = false)
|
||||
@ColumnDefault("0")
|
||||
private MFAType mfaType;
|
||||
|
||||
/**
|
||||
* two factor auth key
|
||||
*/
|
||||
@Column(name = "mfa_key", length = 64)
|
||||
private String mfaKey;
|
||||
|
||||
@Override
|
||||
public void prePersist() {
|
||||
|
|
|
@ -65,7 +65,17 @@ public enum LogType implements ValueEnum<Integer> {
|
|||
/**
|
||||
* Sheet deleted
|
||||
*/
|
||||
SHEET_DELETED(60);
|
||||
SHEET_DELETED(60),
|
||||
|
||||
/**
|
||||
* MFA Updated
|
||||
*/
|
||||
MFA_UPDATED(65),
|
||||
|
||||
/**
|
||||
* Logged pre check
|
||||
*/
|
||||
LOGGED_PRE_CHECK(70);
|
||||
|
||||
private final Integer value;
|
||||
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
package run.halo.app.model.enums;
|
||||
|
||||
/**
|
||||
* MFA type.
|
||||
*
|
||||
* @author xun404
|
||||
*/
|
||||
public enum MFAType implements ValueEnum<Integer> {
|
||||
|
||||
/**
|
||||
* Disable MFA auth.
|
||||
*/
|
||||
NONE(0),
|
||||
|
||||
/**
|
||||
* Time-based One-time Password (rfc6238).
|
||||
* see: https://tools.ietf.org/html/rfc6238
|
||||
*/
|
||||
TFA_TOTP(1);
|
||||
|
||||
private final Integer value;
|
||||
|
||||
MFAType(Integer value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static boolean useMFA(MFAType mfaType) {
|
||||
return mfaType != null && MFAType.NONE != mfaType;
|
||||
}
|
||||
}
|
|
@ -24,4 +24,7 @@ public class LoginParam {
|
|||
@Size(max = 100, message = "用户密码字符长度不能超过 {max}")
|
||||
private String password;
|
||||
|
||||
@Size(min = 6, max = 6, message = "两步验证码应为 {max} 位")
|
||||
private String authcode;
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
package run.halo.app.model.params;
|
||||
|
||||
import lombok.Data;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Multi-Factor Auth Param.
|
||||
*
|
||||
* @author xun404
|
||||
* @date 2020-3-26
|
||||
*/
|
||||
@Data
|
||||
public class MultiFactorAuthParam {
|
||||
|
||||
private MFAType mfaType = MFAType.NONE;
|
||||
|
||||
private String mfaKey;
|
||||
|
||||
@NotBlank(message = "MFA Code不能为空")
|
||||
@Size(min = 6, max = 6, message = "MFA Code应为 {max} 位")
|
||||
private String authcode;
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package run.halo.app.model.vo;
|
||||
|
||||
import lombok.*;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
|
||||
/**
|
||||
* MultiFactorAuth VO.
|
||||
*
|
||||
* @author Mu_Wooo
|
||||
* @date 2020-03-30
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@ToString
|
||||
public class MultiFactorAuthVO {
|
||||
|
||||
private String qrImage;
|
||||
|
||||
private String optAuthUrl;
|
||||
|
||||
private String mfaKey;
|
||||
|
||||
private MFAType mfaType;
|
||||
|
||||
public MultiFactorAuthVO(MFAType mfaType) {
|
||||
this.mfaType = mfaType;
|
||||
}
|
||||
}
|
|
@ -63,7 +63,8 @@ public class AdminAuthenticationFilter extends AbstractAuthenticationFilter {
|
|||
"/api/admin/migrations/halo",
|
||||
"/api/admin/is_installed",
|
||||
"/api/admin/password/code",
|
||||
"/api/admin/password/reset"
|
||||
"/api/admin/password/reset",
|
||||
"/api/admin/login/precheck"
|
||||
);
|
||||
|
||||
// set failure handler
|
||||
|
|
|
@ -2,7 +2,9 @@ package run.halo.app.service;
|
|||
|
||||
import org.springframework.lang.NonNull;
|
||||
import run.halo.app.model.dto.EnvironmentDTO;
|
||||
import run.halo.app.model.dto.LoginPreCheckDTO;
|
||||
import run.halo.app.model.dto.StatisticDTO;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.params.LoginParam;
|
||||
import run.halo.app.model.params.ResetPasswordParam;
|
||||
import run.halo.app.security.token.AuthToken;
|
||||
|
@ -28,13 +30,22 @@ public interface AdminService {
|
|||
String LOG_PATH = "logs/spring.log";
|
||||
|
||||
/**
|
||||
* Authenticates.
|
||||
* Authenticates username password.
|
||||
*
|
||||
* @param loginParam login param must not be null
|
||||
* @return authentication token
|
||||
* @return User
|
||||
*/
|
||||
@NonNull
|
||||
AuthToken authenticate(@NonNull LoginParam loginParam);
|
||||
User authenticate(@NonNull LoginParam loginParam);
|
||||
|
||||
/**
|
||||
* Check authCode and build authToken.
|
||||
*
|
||||
* @param loginParam login param must not be null
|
||||
* @return User
|
||||
*/
|
||||
@NonNull
|
||||
AuthToken authCodeCheck(@NonNull LoginParam loginParam);
|
||||
|
||||
/**
|
||||
* Clears authentication.
|
||||
|
@ -107,4 +118,12 @@ public interface AdminService {
|
|||
* @return logs content.
|
||||
*/
|
||||
String getLogFiles(@NonNull Long lines);
|
||||
|
||||
/**
|
||||
* Get user login env
|
||||
*
|
||||
* @param username username must not be null
|
||||
* @return LoginEnvDTO
|
||||
*/
|
||||
LoginPreCheckDTO getUserEnv(@NonNull String username);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import org.springframework.lang.Nullable;
|
|||
import run.halo.app.exception.ForbiddenException;
|
||||
import run.halo.app.exception.NotFoundException;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.model.params.UserParam;
|
||||
import run.halo.app.service.base.CrudService;
|
||||
|
||||
|
@ -133,4 +134,16 @@ public interface UserService extends CrudService<User, Integer> {
|
|||
* @return boolean
|
||||
*/
|
||||
boolean verifyUser(@NonNull String username, @NonNull String password);
|
||||
|
||||
/**
|
||||
* Updates user Multi-Factor Auth.
|
||||
*
|
||||
* @param mfaType Multi-Factor Auth Type.
|
||||
* @param mfaKey Multi-Factor Auth Key.
|
||||
* @param userId user id must not be null
|
||||
* @return updated user detail
|
||||
*/
|
||||
@NonNull
|
||||
User updateMFA(@NonNull MFAType mfaType, String mfaKey, @NonNull Integer userId);
|
||||
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.service.impl;
|
|||
import cn.hutool.core.io.file.FileReader;
|
||||
import cn.hutool.core.lang.Validator;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
@ -19,10 +20,12 @@ import run.halo.app.exception.NotFoundException;
|
|||
import run.halo.app.exception.ServiceException;
|
||||
import run.halo.app.mail.MailService;
|
||||
import run.halo.app.model.dto.EnvironmentDTO;
|
||||
import run.halo.app.model.dto.LoginPreCheckDTO;
|
||||
import run.halo.app.model.dto.StatisticDTO;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.CommentStatus;
|
||||
import run.halo.app.model.enums.LogType;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.model.enums.PostStatus;
|
||||
import run.halo.app.model.params.LoginParam;
|
||||
import run.halo.app.model.params.ResetPasswordParam;
|
||||
|
@ -35,6 +38,7 @@ import run.halo.app.security.util.SecurityUtils;
|
|||
import run.halo.app.service.*;
|
||||
import run.halo.app.utils.FileUtils;
|
||||
import run.halo.app.utils.HaloUtils;
|
||||
import run.halo.app.utils.TwoFactorAuthUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
@ -124,7 +128,7 @@ public class AdminServiceImpl implements AdminService {
|
|||
|
||||
|
||||
@Override
|
||||
public AuthToken authenticate(LoginParam loginParam) {
|
||||
public User authenticate(LoginParam loginParam) {
|
||||
Assert.notNull(loginParam, "Login param must not be null");
|
||||
|
||||
String username = loginParam.getUsername();
|
||||
|
@ -136,7 +140,7 @@ public class AdminServiceImpl implements AdminService {
|
|||
try {
|
||||
// Get user by username or email
|
||||
user = Validator.isEmail(username) ?
|
||||
userService.getByEmailOfNonNull(username) : userService.getByUsernameOfNonNull(username);
|
||||
userService.getByEmailOfNonNull(username) : userService.getByUsernameOfNonNull(username);
|
||||
} catch (NotFoundException e) {
|
||||
log.error("Failed to find user by name: " + username, e);
|
||||
eventPublisher.publishEvent(new LogEvent(this, loginParam.getUsername(), LogType.LOGIN_FAILED, loginParam.getUsername()));
|
||||
|
@ -153,6 +157,22 @@ public class AdminServiceImpl implements AdminService {
|
|||
throw new BadRequestException(mismatchTip);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthToken authCodeCheck(LoginParam loginParam) {
|
||||
// get user
|
||||
final User user = this.authenticate(loginParam);
|
||||
|
||||
// check authCode
|
||||
if (MFAType.useMFA(user.getMfaType())) {
|
||||
if (StrUtil.isBlank(loginParam.getAuthcode())) {
|
||||
throw new BadRequestException("请输入两步验证码");
|
||||
}
|
||||
TwoFactorAuthUtils.validateTFACode(user.getMfaKey(), loginParam.getAuthcode());
|
||||
}
|
||||
|
||||
if (SecurityContextHolder.getContext().isAuthenticated()) {
|
||||
// If the user has been logged in
|
||||
throw new BadRequestException("您已登录,请不要重复登录");
|
||||
|
@ -302,14 +322,14 @@ public class AdminServiceImpl implements AdminService {
|
|||
Assert.hasText(refreshToken, "Refresh token must not be blank");
|
||||
|
||||
Integer userId = cacheStore.getAny(SecurityUtils.buildTokenRefreshKey(refreshToken), Integer.class)
|
||||
.orElseThrow(() -> new BadRequestException("登录状态已失效,请重新登录").setErrorData(refreshToken));
|
||||
.orElseThrow(() -> new BadRequestException("登录状态已失效,请重新登录").setErrorData(refreshToken));
|
||||
|
||||
// Get user info
|
||||
User user = userService.getById(userId);
|
||||
|
||||
// Remove all token
|
||||
cacheStore.getAny(SecurityUtils.buildAccessTokenKey(user), String.class)
|
||||
.ifPresent(accessToken -> cacheStore.delete(SecurityUtils.buildTokenAccessKey(accessToken)));
|
||||
.ifPresent(accessToken -> cacheStore.delete(SecurityUtils.buildTokenAccessKey(accessToken)));
|
||||
cacheStore.delete(SecurityUtils.buildTokenRefreshKey(refreshToken));
|
||||
cacheStore.delete(SecurityUtils.buildAccessTokenKey(user));
|
||||
cacheStore.delete(SecurityUtils.buildRefreshTokenKey(user));
|
||||
|
@ -324,8 +344,8 @@ public class AdminServiceImpl implements AdminService {
|
|||
ResponseEntity<Map> responseEntity = restTemplate.getForEntity(HaloConst.HALO_ADMIN_RELEASES_LATEST, Map.class);
|
||||
|
||||
if (responseEntity == null ||
|
||||
responseEntity.getStatusCode().isError() ||
|
||||
responseEntity.getBody() == null) {
|
||||
responseEntity.getStatusCode().isError() ||
|
||||
responseEntity.getBody() == null) {
|
||||
log.debug("Failed to request remote url: [{}]", HALO_ADMIN_RELEASES_LATEST);
|
||||
throw new ServiceException("系统无法访问到 Github 的 API").setErrorData(HALO_ADMIN_RELEASES_LATEST);
|
||||
}
|
||||
|
@ -339,17 +359,17 @@ public class AdminServiceImpl implements AdminService {
|
|||
try {
|
||||
List assets = (List) assetsObject;
|
||||
Map assetMap = (Map) assets.stream()
|
||||
.filter(assetPredicate())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("Halo admin 最新版暂无资源文件,请稍后再试"));
|
||||
.filter(assetPredicate())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("Halo admin 最新版暂无资源文件,请稍后再试"));
|
||||
|
||||
Object browserDownloadUrl = assetMap.getOrDefault("browser_download_url", "");
|
||||
// Download the assets
|
||||
ResponseEntity<byte[]> downloadResponseEntity = restTemplate.getForEntity(browserDownloadUrl.toString(), byte[].class);
|
||||
|
||||
if (downloadResponseEntity == null ||
|
||||
downloadResponseEntity.getStatusCode().isError() ||
|
||||
downloadResponseEntity.getBody() == null) {
|
||||
downloadResponseEntity.getStatusCode().isError() ||
|
||||
downloadResponseEntity.getBody() == null) {
|
||||
throw new ServiceException("Failed to request remote url: " + browserDownloadUrl.toString()).setErrorData(browserDownloadUrl.toString());
|
||||
}
|
||||
|
||||
|
@ -362,7 +382,7 @@ public class AdminServiceImpl implements AdminService {
|
|||
|
||||
// Create temp folder
|
||||
Path assetTempPath = FileUtils.createTempDirectory()
|
||||
.resolve(assetMap.getOrDefault("name", "halo-admin-latest.zip").toString());
|
||||
.resolve(assetMap.getOrDefault("name", "halo-admin-latest.zip").toString());
|
||||
|
||||
// Unzip
|
||||
FileUtils.unzip(downloadResponseEntity.getBody(), assetTempPath);
|
||||
|
@ -517,9 +537,25 @@ public class AdminServiceImpl implements AdminService {
|
|||
|
||||
linesArray.forEach(line -> {
|
||||
result.append(line)
|
||||
.append(StringUtils.LF);
|
||||
.append(StringUtils.LF);
|
||||
});
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginPreCheckDTO getUserEnv(@NonNull String username) {
|
||||
Assert.notNull(username, "username must not be null");
|
||||
|
||||
boolean useMFA = true;
|
||||
try {
|
||||
final User user = Validator.isEmail(username) ?
|
||||
userService.getByEmailOfNonNull(username) : userService.getByUsernameOfNonNull(username);
|
||||
useMFA = MFAType.useMFA(user.getMfaType());
|
||||
} catch (NotFoundException e) {
|
||||
log.error("Failed to find user by name: " + username, e);
|
||||
eventPublisher.publishEvent(new LogEvent(this, username, LogType.LOGIN_FAILED, username));
|
||||
}
|
||||
return new LoginPreCheckDTO(useMFA);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import run.halo.app.exception.NotFoundException;
|
|||
import run.halo.app.exception.ServiceException;
|
||||
import run.halo.app.model.entity.User;
|
||||
import run.halo.app.model.enums.LogType;
|
||||
import run.halo.app.model.enums.MFAType;
|
||||
import run.halo.app.model.params.UserParam;
|
||||
import run.halo.app.repository.UserRepository;
|
||||
import run.halo.app.service.UserService;
|
||||
|
@ -180,6 +181,8 @@ public class UserServiceImpl extends AbstractCrudService<User, Integer> implemen
|
|||
Assert.hasText(plainPassword, "Plain password must not be blank");
|
||||
|
||||
user.setPassword(BCrypt.hashpw(plainPassword, BCrypt.gensalt()));
|
||||
user.setMfaType(MFAType.NONE);
|
||||
user.setMfaKey(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -187,4 +190,23 @@ public class UserServiceImpl extends AbstractCrudService<User, Integer> implemen
|
|||
User user = getCurrentUser().orElseThrow(() -> new ServiceException("未查询到博主信息"));
|
||||
return user.getUsername().equals(username) && user.getEmail().equals(password);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public User updateMFA(@NonNull MFAType mfaType, String mfaKey,@NonNull Integer userId) {
|
||||
Assert.notNull(mfaType, "MFA Type must not be null");
|
||||
|
||||
// get User
|
||||
User user = getById(userId);
|
||||
// set MFA
|
||||
user.setMfaType(mfaType);
|
||||
user.setMfaKey((MFAType.NONE == mfaType) ? null : mfaKey);
|
||||
// Update this user
|
||||
User updatedUser = update(user);
|
||||
// Log it
|
||||
eventPublisher.publishEvent(new LogEvent(this, updatedUser.getId().toString(), LogType.MFA_UPDATED, "MFA Type:" + mfaType));
|
||||
|
||||
return updatedUser;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,399 @@
|
|||
package run.halo.app.utils;
|
||||
|
||||
import run.halo.app.exception.AuthenticationException;
|
||||
import run.halo.app.exception.BadRequestException;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
public class TwoFactorAuthUtils {
|
||||
|
||||
private final static int VALID_TFA_WINDOW_MILLIS = 60_000;
|
||||
|
||||
public static String generateTFAKey() {
|
||||
return TimeBasedOneTimePasswordUtil.generateBase32Secret(32);
|
||||
}
|
||||
|
||||
public static String generateTFACode(String tfaKey) {
|
||||
try {
|
||||
return TimeBasedOneTimePasswordUtil.generateCurrentNumberString(tfaKey);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new AuthenticationException("两步验证码生成异常");
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateTFACode(String tfaKey, String tfaCode) {
|
||||
try {
|
||||
int validCode = Integer.parseInt(tfaCode);
|
||||
boolean result = TimeBasedOneTimePasswordUtil.validateCurrentNumber(tfaKey, validCode, VALID_TFA_WINDOW_MILLIS);
|
||||
if (!result) throw new BadRequestException("两步验证码验证错误,请确认时间是否同步");
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BadRequestException("两步验证码请输入数字");
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new BadRequestException("两步验证码验证异常");
|
||||
}
|
||||
}
|
||||
|
||||
public static String generateOtpAuthUrl(final String userName, final String tfaKey) {
|
||||
return TimeBasedOneTimePasswordUtil.generateOtpAuthUrl(userName, tfaKey);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Java implementation for the Time-based One-Time Password (TOTP) two factor authentication algorithm. To get this to
|
||||
* work you:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Use generateBase32Secret() to generate a secret key for a user.</li>
|
||||
* <li>Store the secret key in the database associated with the user account.</li>
|
||||
* <li>Display the QR image URL returned by qrImageUrl(...) to the user.</li>
|
||||
* <li>User uses the image to load the secret key into his authenticator application.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* Whenever the user logs in:
|
||||
* </p>
|
||||
*
|
||||
* <ol>
|
||||
* <li>The user enters the number from the authenticator application into the login form.</li>
|
||||
* <li>Read the secret associated with the user account from the database.</li>
|
||||
* <li>The server compares the user input with the output from generateCurrentNumber(...).</li>
|
||||
* <li>If they are equal then the user is allowed to log in.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* See: https://github.com/j256/two-factor-auth
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* For more details about this magic algorithm, see: http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm
|
||||
* </p>
|
||||
*
|
||||
* @author graywatson
|
||||
*/
|
||||
class TimeBasedOneTimePasswordUtil {
|
||||
|
||||
/**
|
||||
* default time-step which is part of the spec, 30 seconds is default
|
||||
*/
|
||||
public static final int DEFAULT_TIME_STEP_SECONDS = 30;
|
||||
/**
|
||||
* set to the number of digits to control 0 prefix, set to 0 for no prefix
|
||||
*/
|
||||
private static int NUM_DIGITS_OUTPUT = 6;
|
||||
|
||||
private static final String BLOCK_OF_ZEROS;
|
||||
|
||||
static {
|
||||
char[] chars = new char[NUM_DIGITS_OUTPUT];
|
||||
Arrays.fill(chars, '0');
|
||||
BLOCK_OF_ZEROS = new String(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return a 16-character secret key in base32 format (A-Z2-7) using {@link SecureRandom}. Could be used
|
||||
* to generate the QR image to be shared with the user. Other lengths should use {@link #generateBase32Secret(int)}.
|
||||
*/
|
||||
public static String generateBase32Secret() {
|
||||
return generateBase32Secret(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link #generateBase32Secret()} but specifies a character length.
|
||||
*/
|
||||
public static String generateBase32Secret(int length) {
|
||||
StringBuilder sb = new StringBuilder(length);
|
||||
Random random = new SecureRandom();
|
||||
for (int i = 0; i < length; i++) {
|
||||
int val = random.nextInt(32);
|
||||
if (val < 26) {
|
||||
sb.append((char) ('A' + val));
|
||||
} else {
|
||||
sb.append((char) ('2' + (val - 26)));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a given secret-number using the secret base-32 string. This allows you to set a window in milliseconds
|
||||
* to account for people being close to the end of the time-step. For example, if windowMillis is 10000 then this
|
||||
* method will check the authNumber against the generated number from 10 seconds before now through 10 seconds after
|
||||
* now.
|
||||
*
|
||||
* <p>
|
||||
* WARNING: This requires a system clock that is in sync with the world.
|
||||
* </p>
|
||||
*
|
||||
* @param base32Secret Secret string encoded using base-32 that was used to generate the QR code or shared with the user.
|
||||
* @param authNumber Time based number provided by the user from their authenticator application.
|
||||
* @param windowMillis Number of milliseconds that they are allowed to be off and still match. This checks before and after
|
||||
* the current time to account for clock variance. Set to 0 for no window.
|
||||
* @return True if the authNumber matched the calculated number within the specified window.
|
||||
*/
|
||||
public static boolean validateCurrentNumber(String base32Secret, int authNumber, int windowMillis)
|
||||
throws GeneralSecurityException {
|
||||
return validateCurrentNumber(base32Secret, authNumber, windowMillis, System.currentTimeMillis(),
|
||||
DEFAULT_TIME_STEP_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link #validateCurrentNumber(String, int, int)} except exposes other parameters. Mostly for testing.
|
||||
*
|
||||
* @param base32Secret Secret string encoded using base-32 that was used to generate the QR code or shared with the user.
|
||||
* @param authNumber Time based number provided by the user from their authenticator application.
|
||||
* @param windowMillis Number of milliseconds that they are allowed to be off and still match. This checks before and after
|
||||
* the current time to account for clock variance. Set to 0 for no window.
|
||||
* @param timeMillis Time in milliseconds.
|
||||
* @param timeStepSeconds Time step in seconds. The default value is 30 seconds here. See {@link #DEFAULT_TIME_STEP_SECONDS}.
|
||||
* @return True if the authNumber matched the calculated number within the specified window.
|
||||
*/
|
||||
public static boolean validateCurrentNumber(String base32Secret, int authNumber, int windowMillis, long timeMillis,
|
||||
int timeStepSeconds) throws GeneralSecurityException {
|
||||
long fromTimeMillis = timeMillis;
|
||||
long toTimeMillis = timeMillis;
|
||||
if (windowMillis > 0) {
|
||||
fromTimeMillis -= windowMillis;
|
||||
toTimeMillis += windowMillis;
|
||||
}
|
||||
long timeStepMillis = timeStepSeconds * 1000;
|
||||
for (long millis = fromTimeMillis; millis <= toTimeMillis; millis += timeStepMillis) {
|
||||
int generatedNumber = generateNumber(base32Secret, millis, timeStepSeconds);
|
||||
if (generatedNumber == authNumber) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current number to be checked. This can be compared against user input.
|
||||
*
|
||||
* <p>
|
||||
* WARNING: This requires a system clock that is in sync with the world.
|
||||
* </p>
|
||||
*
|
||||
* @param base32Secret Secret string encoded using base-32 that was used to generate the QR code or shared with the user.
|
||||
* @return A number as a string with possible leading zeros which should match the user's authenticator application
|
||||
* output.
|
||||
*/
|
||||
public static String generateCurrentNumberString(String base32Secret) throws GeneralSecurityException {
|
||||
return generateNumberString(base32Secret, System.currentTimeMillis(), DEFAULT_TIME_STEP_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link #generateCurrentNumberString(String)} except exposes other parameters. Mostly for testing.
|
||||
*
|
||||
* @param base32Secret Secret string encoded using base-32 that was used to generate the QR code or shared with the user.
|
||||
* @param timeMillis Time in milliseconds.
|
||||
* @param timeStepSeconds Time step in seconds. The default value is 30 seconds here. See {@link #DEFAULT_TIME_STEP_SECONDS}.
|
||||
* @return A number as a string with possible leading zeros which should match the user's authenticator application
|
||||
* output.
|
||||
*/
|
||||
public static String generateNumberString(String base32Secret, long timeMillis, int timeStepSeconds)
|
||||
throws GeneralSecurityException {
|
||||
int number = generateNumber(base32Secret, timeMillis, timeStepSeconds);
|
||||
return zeroPrepend(number, NUM_DIGITS_OUTPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link #generateCurrentNumberString(String)} but this returns a int instead of a string.
|
||||
*
|
||||
* @return A number which should match the user's authenticator application output.
|
||||
*/
|
||||
public static int generateCurrentNumber(String base32Secret) throws GeneralSecurityException {
|
||||
return generateNumber(base32Secret, System.currentTimeMillis(), DEFAULT_TIME_STEP_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link #generateNumberString(String, long, int)} but this returns a int instead of a string.
|
||||
*
|
||||
* @return A number which should match the user's authenticator application output.
|
||||
*/
|
||||
public static int generateNumber(String base32Secret, long timeMillis, int timeStepSeconds)
|
||||
throws GeneralSecurityException {
|
||||
|
||||
byte[] key = decodeBase32(base32Secret);
|
||||
|
||||
byte[] data = new byte[8];
|
||||
long value = timeMillis / 1000 / timeStepSeconds;
|
||||
for (int i = 7; value > 0; i--) {
|
||||
data[i] = (byte) (value & 0xFF);
|
||||
value >>= 8;
|
||||
}
|
||||
|
||||
// encrypt the data with the key and return the SHA1 of it in hex
|
||||
SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
|
||||
// if this is expensive, could put in a thread-local
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(signKey);
|
||||
byte[] hash = mac.doFinal(data);
|
||||
|
||||
// take the 4 least significant bits from the encrypted string as an offset
|
||||
int offset = hash[hash.length - 1] & 0xF;
|
||||
|
||||
// We're using a long because Java hasn't got unsigned int.
|
||||
long truncatedHash = 0;
|
||||
for (int i = offset; i < offset + 4; ++i) {
|
||||
truncatedHash <<= 8;
|
||||
// get the 4 bytes at the offset
|
||||
truncatedHash |= hash[i] & 0xFF;
|
||||
}
|
||||
// cut off the top bit
|
||||
truncatedHash &= 0x7FFFFFFF;
|
||||
|
||||
// the token is then the last 6 digits in the number
|
||||
truncatedHash %= 1000000;
|
||||
// this is only 6 digits so we can safely case it
|
||||
return (int) truncatedHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the QR image url thanks to Google. This can be shown to the user and scanned by the authenticator program
|
||||
* as an easy way to enter the secret.
|
||||
*
|
||||
* @param keyId Name of the key that you want to show up in the users authentication application. Should already be
|
||||
* URL encoded.
|
||||
* @param secret Secret string that will be used when generating the current number.
|
||||
*/
|
||||
public static String qrImageUrl(String keyId, String secret) {
|
||||
StringBuilder sb = new StringBuilder(128);
|
||||
sb.append("https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=200x200&chld=M|0&cht=qr&chl=");
|
||||
addOtpAuthPart(keyId, secret, sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the otp-auth part of the QR image which is suitable to be injected into other QR generators (e.g. JS
|
||||
* generator).
|
||||
*
|
||||
* @param keyId Name of the key that you want to show up in the users authentication application. Should already be
|
||||
* URL encoded.
|
||||
* @param secret Secret string that will be used when generating the current number.
|
||||
*/
|
||||
public static String generateOtpAuthUrl(String keyId, String secret) {
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
addOtpAuthPart(keyId, secret, sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static void addOtpAuthPart(String keyId, String secret, StringBuilder sb) {
|
||||
sb.append("otpauth://totp/").append(keyId).append("?secret=").append(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the string prepended with 0s. Tested as 10x faster than String.format("%06d", ...); Exposed for testing.
|
||||
*/
|
||||
static String zeroPrepend(int num, int digits) {
|
||||
String numStr = Integer.toString(num);
|
||||
if (numStr.length() >= digits) {
|
||||
return numStr;
|
||||
} else {
|
||||
StringBuilder sb = new StringBuilder(digits);
|
||||
int zeroCount = digits - numStr.length();
|
||||
sb.append(BLOCK_OF_ZEROS, 0, zeroCount);
|
||||
sb.append(numStr);
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base-32 method. I didn't want to add a dependency to Apache Codec just for this decode method. Exposed for
|
||||
* testing.
|
||||
*/
|
||||
static byte[] decodeBase32(String str) {
|
||||
// each base-32 character encodes 5 bits
|
||||
int numBytes = ((str.length() * 5) + 7) / 8;
|
||||
byte[] result = new byte[numBytes];
|
||||
int resultIndex = 0;
|
||||
int which = 0;
|
||||
int working = 0;
|
||||
for (int i = 0; i < str.length(); i++) {
|
||||
char ch = str.charAt(i);
|
||||
int val;
|
||||
if (ch >= 'a' && ch <= 'z') {
|
||||
val = ch - 'a';
|
||||
} else if (ch >= 'A' && ch <= 'Z') {
|
||||
val = ch - 'A';
|
||||
} else if (ch >= '2' && ch <= '7') {
|
||||
val = 26 + (ch - '2');
|
||||
} else if (ch == '=') {
|
||||
// special case
|
||||
which = 0;
|
||||
break;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid base-32 character: " + ch);
|
||||
}
|
||||
/*
|
||||
* There are probably better ways to do this but this seemed the most straightforward.
|
||||
*/
|
||||
switch (which) {
|
||||
case 0:
|
||||
// all 5 bits is top 5 bits
|
||||
working = (val & 0x1F) << 3;
|
||||
which = 1;
|
||||
break;
|
||||
case 1:
|
||||
// top 3 bits is lower 3 bits
|
||||
working |= (val & 0x1C) >> 2;
|
||||
result[resultIndex++] = (byte) working;
|
||||
// lower 2 bits is upper 2 bits
|
||||
working = (val & 0x03) << 6;
|
||||
which = 2;
|
||||
break;
|
||||
case 2:
|
||||
// all 5 bits is mid 5 bits
|
||||
working |= (val & 0x1F) << 1;
|
||||
which = 3;
|
||||
break;
|
||||
case 3:
|
||||
// top 1 bit is lowest 1 bit
|
||||
working |= (val & 0x10) >> 4;
|
||||
result[resultIndex++] = (byte) working;
|
||||
// lower 4 bits is top 4 bits
|
||||
working = (val & 0x0F) << 4;
|
||||
which = 4;
|
||||
break;
|
||||
case 4:
|
||||
// top 4 bits is lowest 4 bits
|
||||
working |= (val & 0x1E) >> 1;
|
||||
result[resultIndex++] = (byte) working;
|
||||
// lower 1 bit is top 1 bit
|
||||
working = (val & 0x01) << 7;
|
||||
which = 5;
|
||||
break;
|
||||
case 5:
|
||||
// all 5 bits is mid 5 bits
|
||||
working |= (val & 0x1F) << 2;
|
||||
which = 6;
|
||||
break;
|
||||
case 6:
|
||||
// top 2 bits is lowest 2 bits
|
||||
working |= (val & 0x18) >> 3;
|
||||
result[resultIndex++] = (byte) working;
|
||||
// lower 3 bits of byte 6 is top 3 bits
|
||||
working = (val & 0x07) << 5;
|
||||
which = 7;
|
||||
break;
|
||||
case 7:
|
||||
// all 5 bits is lower 5 bits
|
||||
working |= val & 0x1F;
|
||||
result[resultIndex++] = (byte) working;
|
||||
which = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (which != 0) {
|
||||
result[resultIndex++] = (byte) working;
|
||||
}
|
||||
if (resultIndex != result.length) {
|
||||
result = Arrays.copyOf(result, resultIndex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package run.halo.app.utils;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Two-Factor Auth Test
|
||||
*
|
||||
* @author Mu_Wooo
|
||||
* @date 2020-04-03 10:10 上午
|
||||
*/
|
||||
@Slf4j
|
||||
public class TwoFactorAuthUtilsTest {
|
||||
|
||||
@Test
|
||||
public void checkTFACodeTest() {
|
||||
// generate new key
|
||||
final String key = TwoFactorAuthUtils.generateTFAKey();
|
||||
// generate url
|
||||
final String totpUrl = TwoFactorAuthUtils.generateOtpAuthUrl("UnitTest", key);
|
||||
log.debug("generate key: {}, totpUrl: {}", key, totpUrl);
|
||||
// generate TFA code
|
||||
final String authCode = TwoFactorAuthUtils.generateTFACode(key);
|
||||
log.debug("TFA code: {}", authCode);
|
||||
// validate TFA code
|
||||
TwoFactorAuthUtils.validateTFACode(key, authCode);
|
||||
log.debug("Success!");
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue