diff --git a/build.gradle b/build.gradle index 6d0f88b36..29579f0ab 100644 --- a/build.gradle +++ b/build.gradle @@ -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" diff --git a/src/main/java/run/halo/app/controller/admin/api/AdminController.java b/src/main/java/run/halo/app/controller/admin/api/AdminController.java index df3a38b21..9af6d2d01 100644 --- a/src/main/java/run/halo/app/controller/admin/api/AdminController.java +++ b/src/main/java/run/halo/app/controller/admin/api/AdminController.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") diff --git a/src/main/java/run/halo/app/controller/admin/api/UserController.java b/src/main/java/run/halo/app/controller/admin/api/UserController.java index 05aa39555..795e349bd 100644 --- a/src/main/java/run/halo/app/controller/admin/api/UserController.java +++ b/src/main/java/run/halo/app/controller/admin/api/UserController.java @@ -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()); + } } diff --git a/src/main/java/run/halo/app/model/dto/LoginPreCheckDTO.java b/src/main/java/run/halo/app/model/dto/LoginPreCheckDTO.java new file mode 100644 index 000000000..b086455f3 --- /dev/null +++ b/src/main/java/run/halo/app/model/dto/LoginPreCheckDTO.java @@ -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; + +} diff --git a/src/main/java/run/halo/app/model/dto/UserDTO.java b/src/main/java/run/halo/app/model/dto/UserDTO.java index 2c787772f..6606d03d7 100644 --- a/src/main/java/run/halo/app/model/dto/UserDTO.java +++ b/src/main/java/run/halo/app/model/dto/UserDTO.java @@ -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 { private String description; + private MFAType mfaType; + private Date createTime; private Date updateTime; diff --git a/src/main/java/run/halo/app/model/entity/User.java b/src/main/java/run/halo/app/model/entity/User.java index f1d62a81e..e61e2affd 100644 --- a/src/main/java/run/halo/app/model/entity/User.java +++ b/src/main/java/run/halo/app/model/entity/User.java @@ -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() { diff --git a/src/main/java/run/halo/app/model/enums/LogType.java b/src/main/java/run/halo/app/model/enums/LogType.java index 3c3ec4438..e23a30596 100644 --- a/src/main/java/run/halo/app/model/enums/LogType.java +++ b/src/main/java/run/halo/app/model/enums/LogType.java @@ -65,7 +65,17 @@ public enum LogType implements ValueEnum { /** * Sheet deleted */ - SHEET_DELETED(60); + SHEET_DELETED(60), + + /** + * MFA Updated + */ + MFA_UPDATED(65), + + /** + * Logged pre check + */ + LOGGED_PRE_CHECK(70); private final Integer value; diff --git a/src/main/java/run/halo/app/model/enums/MFAType.java b/src/main/java/run/halo/app/model/enums/MFAType.java new file mode 100644 index 000000000..a4f07b539 --- /dev/null +++ b/src/main/java/run/halo/app/model/enums/MFAType.java @@ -0,0 +1,35 @@ +package run.halo.app.model.enums; + +/** + * MFA type. + * + * @author xun404 + */ +public enum MFAType implements ValueEnum { + + /** + * 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; + } +} diff --git a/src/main/java/run/halo/app/model/params/LoginParam.java b/src/main/java/run/halo/app/model/params/LoginParam.java index d5e4b2f8e..bb72d1090 100644 --- a/src/main/java/run/halo/app/model/params/LoginParam.java +++ b/src/main/java/run/halo/app/model/params/LoginParam.java @@ -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; + } diff --git a/src/main/java/run/halo/app/model/params/MultiFactorAuthParam.java b/src/main/java/run/halo/app/model/params/MultiFactorAuthParam.java new file mode 100644 index 000000000..834e37fd1 --- /dev/null +++ b/src/main/java/run/halo/app/model/params/MultiFactorAuthParam.java @@ -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; + +} diff --git a/src/main/java/run/halo/app/model/vo/MultiFactorAuthVO.java b/src/main/java/run/halo/app/model/vo/MultiFactorAuthVO.java new file mode 100644 index 000000000..06e90de05 --- /dev/null +++ b/src/main/java/run/halo/app/model/vo/MultiFactorAuthVO.java @@ -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; + } +} diff --git a/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java b/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java index 56c4adcd1..181fa76f7 100644 --- a/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java +++ b/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java @@ -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 diff --git a/src/main/java/run/halo/app/service/AdminService.java b/src/main/java/run/halo/app/service/AdminService.java index 2a3426664..03d95ba42 100644 --- a/src/main/java/run/halo/app/service/AdminService.java +++ b/src/main/java/run/halo/app/service/AdminService.java @@ -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); } diff --git a/src/main/java/run/halo/app/service/UserService.java b/src/main/java/run/halo/app/service/UserService.java index c306c5c28..bcf8f5e79 100755 --- a/src/main/java/run/halo/app/service/UserService.java +++ b/src/main/java/run/halo/app/service/UserService.java @@ -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 { * @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); + } diff --git a/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java b/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java index bbc839681..3a9686068 100644 --- a/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java @@ -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 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 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); + } } diff --git a/src/main/java/run/halo/app/service/impl/UserServiceImpl.java b/src/main/java/run/halo/app/service/impl/UserServiceImpl.java index 78133d6da..17ac073b9 100644 --- a/src/main/java/run/halo/app/service/impl/UserServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/UserServiceImpl.java @@ -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 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 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; + + } } diff --git a/src/main/java/run/halo/app/utils/TwoFactorAuthUtils.java b/src/main/java/run/halo/app/utils/TwoFactorAuthUtils.java new file mode 100644 index 000000000..010b0805d --- /dev/null +++ b/src/main/java/run/halo/app/utils/TwoFactorAuthUtils.java @@ -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: + * + *
    + *
  1. Use generateBase32Secret() to generate a secret key for a user.
  2. + *
  3. Store the secret key in the database associated with the user account.
  4. + *
  5. Display the QR image URL returned by qrImageUrl(...) to the user.
  6. + *
  7. User uses the image to load the secret key into his authenticator application.
  8. + *
+ * + *

+ * Whenever the user logs in: + *

+ * + *
    + *
  1. The user enters the number from the authenticator application into the login form.
  2. + *
  3. Read the secret associated with the user account from the database.
  4. + *
  5. The server compares the user input with the output from generateCurrentNumber(...).
  6. + *
  7. If they are equal then the user is allowed to log in.
  8. + *
+ * + *

+ * See: https://github.com/j256/two-factor-auth + *

+ * + *

+ * For more details about this magic algorithm, see: http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm + *

+ * + * @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. + * + *

+ * WARNING: This requires a system clock that is in sync with the world. + *

+ * + * @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. + * + *

+ * WARNING: This requires a system clock that is in sync with the world. + *

+ * + * @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; + } +} diff --git a/src/test/java/run/halo/app/utils/TwoFactorAuthUtilsTest.java b/src/test/java/run/halo/app/utils/TwoFactorAuthUtilsTest.java new file mode 100644 index 000000000..a161754c7 --- /dev/null +++ b/src/test/java/run/halo/app/utils/TwoFactorAuthUtilsTest.java @@ -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!"); + } + +}