Spring Authorization Server入门 (二十) 实现二维码扫码登录

实现原理

  • 打开网页,发起授权申请/未登录被重定向到登录页面
  • 选择二维码登录,页面从后端请求二维码
  • 页面渲染二维码图片,并轮询请求,获取二维码的状态
  • 事先登录过APP的手机扫描二维码,然后APP请求服务器端的API接口,把用户认证信息传递到服务器中
  • 后端收到APP的请求后更改二维码状态,并把用户认证信息写入session
  • 页面得到扫码确认的响应,并跳转回之前未登录的地址
User Browser AuthorizationServer 访问受限资源 访问受限资源 返回登录页面 选择二维码登录 请求生成二维码 返回二维码图片 渲染图片 轮询检测二维码状态 轮询检测二维码状态 轮询检测二维码状态 未扫描 已登录APP扫码二维码 获取二维码当前状态 二维码状态 轮询检测二维码状态 扫码用户头像、待确认 轮询检测二维码状态 确认登录 认证信息写入session,响应成功 轮询检测二维码状态 扫码成功,用户确认登录 重定向至受限资源 返回受限资源 User Browser AuthorizationServer

代码实现

后端实现

引入二维码依赖

<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.3.0</version>
</dependency>
<dependency><groupId>com.google.zxing</groupId><artifactId>javase</artifactId><version>3.3.0</version>
</dependency>

hutool-captcha改为hutool-all

<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>${hutool.version}</version>
</dependency>

添加二维码登录接口

提供四个接口,分别处理生成二维码、轮询、app扫码和app确认登录的逻辑。

package com.example.controller;import com.example.model.Result;
import com.example.model.request.qrcode.QrCodeLoginConsentRequest;
import com.example.model.request.qrcode.QrCodeLoginScanRequest;
import com.example.model.response.qrcode.QrCodeGenerateResponse;
import com.example.model.response.qrcode.QrCodeLoginFetchResponse;
import com.example.model.response.qrcode.QrCodeLoginScanResponse;
import com.example.service.IQrCodeLoginService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;/*** 二维码登录接口** @author vains*/
@RestController
@AllArgsConstructor
@RequestMapping("/qrCode")
public class QrCodeLoginController {private final IQrCodeLoginService iQrCodeLoginService;@GetMapping("/login/generateQrCode")public Result<QrCodeGenerateResponse> generateQrCode() {// 生成二维码return Result.success(iQrCodeLoginService.generateQrCode());}@GetMapping("/login/fetch/{qrCodeId}")public Result<QrCodeLoginFetchResponse> fetch(@PathVariable String qrCodeId) {// 轮询二维码状态return Result.success(iQrCodeLoginService.fetch(qrCodeId));}@PostMapping("/scan")public Result<QrCodeLoginScanResponse> scan(@RequestBody QrCodeLoginScanRequest loginScan) {// app 扫码二维码return Result.success(iQrCodeLoginService.scan(loginScan));}@PostMapping("/consent")public Result<String> consent(@RequestBody QrCodeLoginConsentRequest loginConsent) {// app 确认登录iQrCodeLoginService.consent(loginConsent);return Result.success();}}

yml中放行前端访问的接口

添加匹配规则/qrCode/login/**

custom:# 自定义认证配置security:# 登录页面路径login-url: http://k7fsqkhtbx.cdhttp.cn/login# 授权确认页面路径consent-page-uri: http://k7fsqkhtbx.cdhttp.cn/consent# 设备码验证页面device-activate-uri: http://k7fsqkhtbx.cdhttp.cn/activate# 设备码验证成功页面device-activated-uri: http://k7fsqkhtbx.cdhttp.cn/activated# 不需要认证的地址ignore-uri-list: assets/**, /webjars/**, /login, /getCaptcha, /getSmsCaptcha, /error, /oauth2/consent/parameters, /test03, /favicon.ico, /qrCode/login/**

编写二维码登录服务接口

编写登录service接口

package com.example.service;import com.example.model.request.qrcode.QrCodeLoginConsentRequest;
import com.example.model.request.qrcode.QrCodeLoginScanRequest;
import com.example.model.response.qrcode.QrCodeGenerateResponse;
import com.example.model.response.qrcode.QrCodeLoginFetchResponse;
import com.example.model.response.qrcode.QrCodeLoginScanResponse;/*** 二维码登录服务接口** @author vains*/
public interface IQrCodeLoginService {/*** 生成二维码** @return 二维码*/QrCodeGenerateResponse generateQrCode();/*** 扫描二维码响应** @param loginScan 二维码id* @return 二维码信息*/QrCodeLoginScanResponse scan(QrCodeLoginScanRequest loginScan);/*** 二维码登录确认入参** @param loginConsent 二维码id*/void consent(QrCodeLoginConsentRequest loginConsent);/*** web端轮询二维码状态处理** @param qrCodeId 二维码id* @return 二维码信息*/QrCodeLoginFetchResponse fetch(String qrCodeId);}

编写生成二维码响应类

生成二维码图片时返回二维码id和图片

package com.example.model.response.qrcode;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;/*** 生成二维码响应** @author vains*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QrCodeGenerateResponse {/*** 二维码id*/private String qrCodeId;/*** 二维码base64值(这里响应一个链接好一些)*/private String imageData;}

编写web前端轮询二维码状态出参

前端根据二维码id轮询二维码状态时返回二维码状态,如果已扫描也会返回扫描者的头像、昵称。

package com.example.model.response.qrcode;import lombok.Data;import java.util.Set;/*** web前端轮询二维码状态出参** @author vains*/
@Data
public class QrCodeLoginFetchResponse {/*** 二维码状态* 0:待扫描,1:已扫描,2:已确认*/private Integer qrCodeStatus;/*** 是否已过期*/private Boolean expired;/*** 扫描人头像*/private String avatarUrl;/*** 扫描人昵称*/private String name;/*** 待确认scope*/private Set<String> scopes;/*** 跳转登录之前请求的接口*/private String beforeLoginRequestUri;/*** 跳转登录之前请求参数*/private String beforeLoginQueryString;}

编写扫描二维码入参

app扫描二维码时传入二维码id

package com.example.model.request.qrcode;import lombok.Data;/*** 扫描二维码入参** @author vains*/
@Data
public class QrCodeLoginScanRequest {/*** 二维码id*/private String qrCodeId;}

编写二维码响应bean

扫描二维码时生成一个临时票据返回,同时返回scope和二维码状态。

package com.example.model.response.qrcode;import lombok.Data;import java.util.Set;/*** 扫描二维码响应bean** @author vains*/
@Data
public class QrCodeLoginScanResponse {/*** 扫描临时票据*/private String qrCodeTicket;/*** 二维码状态*/private Integer qrCodeStatus;/*** 是否已过期*/private Boolean expired;/*** 待确认scope*/private Set<String> scopes;}

编写二维码登录确认登录入参类

确认登录时传入二维码id和上一步生成的临时票据防篡改。

package com.example.model.request.qrcode;import lombok.Data;/*** 二维码登录确认入参** @author vains*/
@Data
public class QrCodeLoginConsentRequest {/*** 二维码id*/private String qrCodeId;/*** 扫码二维码后产生的临时票据(仅一次有效)*/private String qrCodeTicket;}

编写二维码信息类

生成二维码时生成的数据bean,存入redis中,等到前端轮询或app端操作时使用。

package com.example.model.qrcode;import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;import java.time.LocalDateTime;
import java.util.Set;/*** 二维码信息** @author vains*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QrCodeInfo {/*** 二维码id*/private String qrCodeId;/*** 二维码状态* 0:待扫描,1:已扫描,2:已确认*/private Integer qrCodeStatus;/*** 二维码过期时间*/private LocalDateTime expiresTime;/*** 扫描人头像*/private String avatarUrl;/*** 扫描人昵称*/private String name;/*** 待确认的scope*/private Set<String> scopes;/*** 跳转登录之前请求的接口*/private String beforeLoginRequestUri;/*** 跳转登录之前请求参数*/private String beforeLoginQueryString;}

编写二维码登录接口实现

扫码登录实现,具体逻辑请看代码中的注释。

package com.example.service.impl;import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.example.entity.Oauth2BasicUser;
import com.example.model.qrcode.QrCodeInfo;
import com.example.model.request.qrcode.QrCodeLoginConsentRequest;
import com.example.model.request.qrcode.QrCodeLoginScanRequest;
import com.example.model.response.qrcode.QrCodeGenerateResponse;
import com.example.model.response.qrcode.QrCodeLoginFetchResponse;
import com.example.model.response.qrcode.QrCodeLoginScanResponse;
import com.example.property.CustomSecurityProperties;
import com.example.service.IQrCodeLoginService;
import com.example.support.RedisOperator;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.savedrequest.DefaultSavedRequest;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;/*** 二维码登录接口实现** @author vains*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QrCodeLoginServiceImpl implements IQrCodeLoginService {private final RedisOperator<QrCodeInfo> redisOperator;private final RedisOperator<String> stringRedisOperator;private final CustomSecurityProperties customSecurityProperties;private final RedisOAuth2AuthorizationService authorizationService;private final RedisOperator<UsernamePasswordAuthenticationToken> authenticationRedisOperator;/*** 过期时间*/private final long QR_CODE_INFO_TIMEOUT = 60 * 10;/*** 二维码信息前缀*/private final String QR_CODE_PREV = "login:qrcode:";private final RequestCache requestCache = new HttpSessionRequestCache();@Overridepublic QrCodeGenerateResponse generateQrCode() {// 生成二维码唯一idString qrCodeId = IdWorker.getIdStr();// 生成二维码并转为base64String pngQrCode = QrCodeUtil.generateAsBase64(qrCodeId, new QrConfig(), "png");QrCodeInfo info = QrCodeInfo.builder().qrCodeId(qrCodeId)// 待扫描状态.qrCodeStatus(0)// 1分钟后过期.expiresTime(LocalDateTime.now().plusMinutes(2L)).build();// 获取当前requestRequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();if (requestAttributes != null) {// 获取当前sessionHttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();HttpServletResponse response = ((ServletRequestAttributes) requestAttributes).getResponse();DefaultSavedRequest savedRequest = (DefaultSavedRequest) this.requestCache.getRequest(request, response);if (savedRequest != null) {if (!UrlUtils.isAbsoluteUrl(customSecurityProperties.getLoginUrl())) {// 获取查询参数与请求路径String queryString = savedRequest.getQueryString();String requestUri = savedRequest.getRequestURI();// 前后端不分离根据请求路径和请求参数跳转info.setBeforeLoginRequestUri(requestUri);info.setBeforeLoginQueryString(queryString);}// 获取跳转登录之前访问url的query parameterString[] scopes = savedRequest.getParameterValues("scope");if (!ObjectUtils.isEmpty(scopes)) {// 不为空获取第一个并设置进二维码信息中info.setScopes(Set.of(scopes[0].split(" ")));}// 前端可以根据scope显示要获取的信息,或固定显示要获取的信息}}// 因为上边设置的过期时间是2分钟,这里设置10分钟过期,可根据业务自行调整过期时间redisOperator.set(QR_CODE_PREV + qrCodeId, info, QR_CODE_INFO_TIMEOUT);return new QrCodeGenerateResponse(qrCodeId, pngQrCode);}@Overridepublic QrCodeLoginScanResponse scan(QrCodeLoginScanRequest loginScan) {// 应该用validation的Assert.hasLength(loginScan.getQrCodeId(), "二维码Id不能为空.");// 校验二维码状态QrCodeInfo info = redisOperator.get(QR_CODE_PREV + loginScan.getQrCodeId());if (info == null) {throw new RuntimeException("无效二维码.");}// 验证状态if (!Objects.equals(info.getQrCodeStatus(), 0)) {throw new RuntimeException("二维码已被其他人扫描,无法重复扫描.");}// 二维码是否过期boolean qrCodeExpire = info.getExpiresTime().isBefore(LocalDateTime.now());if (qrCodeExpire) {throw new RuntimeException("二维码已过期.");}QrCodeLoginScanResponse loginScanResponse = new QrCodeLoginScanResponse();// 获取登录用户信息OAuth2Authorization oAuth2Authorization = this.getOAuth2Authorization();if (oAuth2Authorization == null) {throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "登录已过期.", null));}// app端使用密码模式、手机认证登录,不使用三方登录的情况UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =oAuth2Authorization.getAttribute(Principal.class.getName());if (usernamePasswordAuthenticationToken.getPrincipal() instanceof Oauth2BasicUser basicUser) {// 生成临时票据String qrCodeTicket = IdWorker.getIdStr();// 根据二维码id和临时票据存储,确认时根据临时票据认证String redisQrCodeTicketKey = String.format("%s%s:%s", QR_CODE_PREV, loginScan.getQrCodeId(), qrCodeTicket);stringRedisOperator.set(redisQrCodeTicketKey, qrCodeTicket, QR_CODE_INFO_TIMEOUT);// 更新二维码信息的状态info.setQrCodeStatus(1);info.setName(basicUser.getName());info.setAvatarUrl(basicUser.getAvatarUrl());redisOperator.set(QR_CODE_PREV + loginScan.getQrCodeId(), info, QR_CODE_INFO_TIMEOUT);// 封装响应loginScanResponse.setQrCodeTicket(qrCodeTicket);loginScanResponse.setQrCodeStatus(0);loginScanResponse.setExpired(Boolean.FALSE);loginScanResponse.setScopes(info.getScopes());}// 其它登录方式暂不处理return loginScanResponse;}@Overridepublic void consent(QrCodeLoginConsentRequest loginConsent) {// 应该用validation的Assert.hasLength(loginConsent.getQrCodeId(), "二维码Id不能为空.");// 校验二维码状态QrCodeInfo info = redisOperator.get(QR_CODE_PREV + loginConsent.getQrCodeId());if (info == null) {throw new RuntimeException("无效二维码或二维码已过期.");}// 验证临时票据String qrCodeTicketKey =String.format("%s%s:%s", QR_CODE_PREV, loginConsent.getQrCodeId(), loginConsent.getQrCodeTicket());String redisQrCodeTicket = stringRedisOperator.get(qrCodeTicketKey);if (!Objects.equals(redisQrCodeTicket, loginConsent.getQrCodeTicket())) {// 临时票据有误、临时票据失效(超过redis存活时间后确认)、redis数据有误if (log.isDebugEnabled()) {log.debug("临时票据有误、临时票据失效(超过redis存活时间后确认)、redis数据有误.");}throw new RuntimeException("登录确认失败,请重新扫描.");}// 使用后删除stringRedisOperator.delete(qrCodeTicketKey);// 获取登录用户信息OAuth2Authorization authorization = this.getOAuth2Authorization();if (authorization == null) {throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "登录已过期.", null));}// app端使用密码模式、手机认证登录,不使用三方登录的情况UsernamePasswordAuthenticationToken authenticationToken = authorization.getAttribute(Principal.class.getName());// 根据二维码id存储用户信息String redisUserinfoKey = String.format("%s%s:%s", QR_CODE_PREV, "userinfo", loginConsent.getQrCodeId());// 存储用户信息authenticationRedisOperator.set(redisUserinfoKey, authenticationToken, QR_CODE_INFO_TIMEOUT);// 更新二维码信息的状态info.setQrCodeStatus(2);redisOperator.set(QR_CODE_PREV + loginConsent.getQrCodeId(), info, QR_CODE_INFO_TIMEOUT);}@Overridepublic QrCodeLoginFetchResponse fetch(String qrCodeId) {// 校验二维码状态QrCodeInfo info = redisOperator.get(QR_CODE_PREV + qrCodeId);if (info == null) {throw new RuntimeException("无效二维码或二维码已过期.");}QrCodeLoginFetchResponse loginFetchResponse = new QrCodeLoginFetchResponse();// 设置二维码是否过期、状态loginFetchResponse.setQrCodeStatus(info.getQrCodeStatus());loginFetchResponse.setExpired(info.getExpiresTime().isBefore(LocalDateTime.now()));if (!Objects.equals(info.getQrCodeStatus(), 0)) {// 如果是已扫描/已确认loginFetchResponse.setName(info.getName());loginFetchResponse.setAvatarUrl(info.getAvatarUrl());}// 如果是已确认,将之前扫码确认的用户信息放入当前session中if (Objects.equals(info.getQrCodeStatus(), 2)) {// 根据二维码id从redis获取用户信息String redisUserinfoKey = String.format("%s%s:%s", QR_CODE_PREV, "userinfo", qrCodeId);UsernamePasswordAuthenticationToken authenticationToken = authenticationRedisOperator.get(redisUserinfoKey);if (authenticationToken != null) {// 获取当前requestRequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();if (requestAttributes == null) {throw new RuntimeException("获取当前Request失败.");}// 获取当前sessionHttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();HttpSession session = request.getSession(Boolean.FALSE);if (session != null) {// 获取到认证信息后将之前扫码确认的用户信息放入当前session中。session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, new SecurityContextImpl(authenticationToken));// 操作成功后移除缓存redisOperator.delete(QR_CODE_PREV + qrCodeId);// 删除用户信息,防止其它人重放请求authenticationRedisOperator.delete(redisUserinfoKey);// 填充二维码数据,设置跳转到登录之前的请求路径、查询参数和是否授权申请请求loginFetchResponse.setBeforeLoginRequestUri(info.getBeforeLoginRequestUri());loginFetchResponse.setBeforeLoginQueryString(info.getBeforeLoginQueryString());}} else {throw new RuntimeException("获取登录确认用户信息失败.");}}return loginFetchResponse;}/*** 获取当前使用token对应的认证信息** @return oauth2认证信息*/private OAuth2Authorization getOAuth2Authorization() {// 校验登录状态Authentication authentication = SecurityContextHolder.getContext().getAuthentication();if (authentication == null) {throw new InsufficientAuthenticationException("未登录.");}if (authentication instanceof JwtAuthenticationToken jwtToken) {// jwt处理String tokenValue = jwtToken.getToken().getTokenValue();// 根据token获取授权登录时的认证信息(登录用户)return authorizationService.findByToken(tokenValue, OAuth2TokenType.ACCESS_TOKEN);}return null;}}

到此后端部分就完成了,全局根据二维码id将整个流程串联起来,前端轮询、app端扫码和登录确认都是通过二维码id来的,中间借助redis来缓存二维码的信息,以确保每个端都可以获取到二维码信息,这样就算集群部署也不影响。

现在默认是将app端当做oauth2登录的,扫码和确认登录都是通过access_token来获取认证信息的,根据请求的token获取oauth2流程中’登录’生成的认证信息。

前端实现

编写二维码登录请求api

编写src/api/QrCodeLogin.ts文件

import loginRequest from '../util/http/LoginRequest'/*** 生成二维码*/
export function generateQrCode() {return loginRequest.get<any>({url: '/qrCode/login/generateQrCode'})
}/*** 获取二维码信息* @param qrCodeId 二维码id*/
export function fetch(qrCodeId: string) {return loginRequest.get<any>({url: `/qrCode/login/fetch/${qrCodeId}`})
}

在登录页面添加二维码登录入口

前端页轮询时如果发现二维码状态变为确认登录则会重定向到之前被拦截后跳转到登录的地址,例如:访问/a发现未登录然后跳转到登录/login,之后扫码登录流程走完以后会重定向至/a。

<script setup lang="ts">
import { ref } from 'vue'
import router from '../../router'
import { getQueryString } from '@/util/GlobalUtils'
import { generateQrCode, fetch } from '@/api/QrCodeLogin'
import { type CountdownProps, createDiscreteApi } from 'naive-ui'
import {getImageCaptcha,getSmsCaptchaByPhone,loginSubmit
} from '@/api/Login'const { message } = createDiscreteApi(['message'])// 登录按钮加载状态
const loading = ref(false)// 定义登录提交的对象
const loginModel = ref({code: '',username: '',password: '',loginType: '',captchaId: ''
})// 图形验证码的base64数据
let captchaImage = ref('')
// 图形验证码的值
let captchaCode = ''
// 是否开始倒计时
const counterActive = ref(false)
// 是否显示三方登录
const showThirdLogin = ref(true)// 定义二维码信息的对象
const qrCodeInfo = ref({qrCodeStatus: 0,expired: false,avatarUrl: '',name: '',scopes: []
})// 生成二维码响应数据
const getQrCodeInfo = ref({qrCodeId: '',imageData: ''
})// 是否自动提交授权确认(二维码登录自动提交)
const autoConsentKey: string = 'autoConsent'/*** 获取图形验证码*/
const getCaptcha = () => {getImageCaptcha().then((result: any) => {if (result.success) {captchaCode = result.data.codecaptchaImage.value = result.data.imageDataloginModel.value.captchaId = result.data.captchaId} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`获取图形验证码失败:${e.message}`)})
}/*** 提交登录表单* @param type 登录类型,passwordLogin是密码模式,smsCaptcha短信登录*/
const submitLogin = (type: string) => {loading.value = trueloginModel.value.loginType = typeloginSubmit(loginModel.value).then((result: any) => {if (result.success) {// 移除自动提交缓存localStorage.removeItem(autoConsentKey)// message.info(`登录成功`)let target = getQueryString('target')if (target) {window.location.href = target} else {// 跳转到首页router.push({ path: '/' })}} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`登录失败:${e.message}`)}).finally(() => {loading.value = false})
}/*** 获取短信验证码*/
const getSmsCaptcha = () => {if (!loginModel.value.username) {message.warning('请先输入手机号.')return}if (!loginModel.value.code) {message.warning('请先输入验证码.')return}if (loginModel.value.code !== captchaCode) {message.warning('验证码错误.')return}getSmsCaptchaByPhone({ phone: loginModel.value.username }).then((result: any) => {if (result.success) {message.info(`获取短信验证码成功,固定为:${result.data}`)counterActive.value = true} else {message.warning(result.message)}}).catch((e: any) => {message.warning(`获取短信验证码失败:${e.message}`)})
}/*** 切换时更新验证码* @param name tab的名字*/
const handleUpdateValue = (name: string) => {// 二维码登录时隐藏三方登录showThirdLogin.value = name !== 'qrcode'if (!showThirdLogin.value) {refreshQrCode()} else {getCaptcha()}
}/*** 生成二维码*/
const refreshQrCode = () => {generateQrCode().then((r) => {getQrCodeInfo.value.qrCodeId = r.data.qrCodeIdgetQrCodeInfo.value.imageData = r.data.imageData// 开始轮询获取二维码信息fetchQrCodeInfo(r.data.qrCodeId);}).catch((e: any) => {message.warning(`生成二维码失败:${e.message}`)})
}/*** 根据二维码id轮询二维码信息* @param qrCodeId 二维码id*/
const fetchQrCodeInfo = (qrCodeId: string) => {fetch(qrCodeId).then((r: any) => {if (r.success) {qrCodeInfo.value = r.dataif (qrCodeInfo.value.qrCodeStatus !== 0 && qrCodeInfo.value.avatarUrl) {// 只要不是待扫描并且头像不为空getQrCodeInfo.value.imageData = qrCodeInfo.value.avatarUrl}if (r.data.qrCodeStatus !== 2 && !qrCodeInfo.value.expired) {if (!showThirdLogin.value) {// 显示三方登录代表不是二维码登录,不轮询;否则继续轮询// 1秒后重复调用setTimeout(() => {fetchQrCodeInfo(qrCodeId)}, 1000);}return}if (qrCodeInfo.value.expired) {// 二维码过期return}if (qrCodeInfo.value.qrCodeStatus === 2) {// 已确认let href = getQueryString('target')if (href) {// 确认后将地址重定向window.location.href = href} else {// 跳转到首页router.push({ path: '/' })}}} else {message.warning(r.message)}}).catch((e: any) => {message.warning(`获取二维码信息失败:${e.message || e.statusText}`)})
}/*** 倒计时结束*/
const onFinish = () => {counterActive.value = false
}/*** 倒计时显示内容*/
const renderCountdown: CountdownProps['render'] = ({ hours, minutes, seconds }) => {return `${seconds}`
}/*** 根据类型发起OAuth2授权申请* @param type 三方OAuth2登录提供商类型*/
const thirdLogin = (type: string) => {window.location.href = `${import.meta.env.VITE_OAUTH_ISSUER}/oauth2/authorization/${type}`
}getCaptcha()
</script><template><header><img alt="Vue logo" class="logo" src="../../assets/logo.svg" width="125" height="125" /><div class="wrapper"><HelloWorld msg="统一认证平台" /></div></header><main><n-card title=""><n-tabs default-value="signin" size="large" justify-content="space-evenly" @update:value="handleUpdateValue"><n-tab-pane name="signin" tab="账号登录"><n-form><n-form-item-row label="用户名"><n-input v-model:value="loginModel.username" placeholder="手机号 / 邮箱" /></n-form-item-row><n-form-item-row label="密码"><n-input v-model:value="loginModel.password" type="password" show-password-on="mousedown"placeholder="密码" /></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-input v-model:value="loginModel.code" placeholder="请输入验证码" /><n-image @click="getCaptcha" width="130" height="34" :src="captchaImage" preview-disabled /></n-input-group></n-form-item-row></n-form><n-button type="info" :loading="loading" @click="submitLogin('passwordLogin')" block strong>登录</n-button></n-tab-pane><n-tab-pane name="signup" tab="短信登录"><n-form><n-form-item-row label="手机号"><n-input v-model:value="loginModel.username" placeholder="手机号 / 邮箱" /></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-input v-model:value="loginModel.code" placeholder="请输入验证码" /><n-image @click="getCaptcha" width="130" height="34" :src="captchaImage" preview-disabled /></n-input-group></n-form-item-row><n-form-item-row label="验证码"><n-input-group><n-input v-model:value="loginModel.password" placeholder="请输入验证码" /><n-button type="info" @click="getSmsCaptcha" style="width: 130px" :disabled="counterActive">获取验证码<span v-if="counterActive">(<n-countdown :render="renderCountdown" :on-finish="onFinish" :duration="59 * 1000":active="counterActive" />)</span></n-button></n-input-group></n-form-item-row></n-form><n-button type="info" :loading="loading" @click="submitLogin('smsCaptcha')" block strong>登录</n-button></n-tab-pane><n-tab-pane name="qrcode" tab="扫码登录" style="text-align: center"><div style="margin: 5.305px"><n-image width="300" :src="getQrCodeInfo.imageData" preview-disabled /></div></n-tab-pane></n-tabs><n-divider style="font-size: 80%; color: #909399">{{ showThirdLogin ? '其它登录方式' : '使用app扫描二维码登录' }}</n-divider><div class="other_login_icon" v-if="showThirdLogin"><IconGitee :size="32" @click="thirdLogin('gitee')" class="icon_item" /><img width="36" height="36" @click="thirdLogin('github')" src="../../assets/GitHub-Mark.png" class="icon_item" /><img width="28" height="28" @click="thirdLogin('wechat')" src="../../assets/wechat_login.png" class="icon_item" /></div></n-card></main>
</template><style scoped>
.other_login_icon {display: flex;align-items: center;justify-content: center;gap: 0 10px;position: relative;margin-top: -5px;
}.icon_item {cursor: pointer;
}header {line-height: 1.5;
}.logo {display: block;margin: 0 auto 2rem;
}@media (min-width: 1024px) {header {display: flex;place-items: center;padding-right: calc(var(--section-gap) / 2);}.logo {margin: 0 2rem 0 0;}header .wrapper {display: flex;place-items: flex-start;flex-wrap: wrap;}
}
</style>

具体修改内容请看代码仓库qrcode_login分支的二维码登录前端登录页面实现。

效果

二维码登录演示

附录

代码仓库:Gitee、Github

参考资料

SpringBoot二维码登录(中)
反向工程解析QQ扫码登录的OAuth2流程
聊一聊二维码扫描登录原理

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/421774.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[pytorch入门] 5. DataLoader的使用

简介 dataset&#xff1a;数据集&#xff0c;提供数据 dataloader&#xff1a;数据加载器&#xff0c;对数据进行加载&#xff0c;可以讲数据加载到神经网络当中 从dataset中取数据时&#xff0c;通过在dataloader中设置参数来确定取数据的方式 用法 要from torch.utils.dat…

基于CanvasLabel的Leaflet矢量数据免切片属性标注实践

目录 前言 一、Leaflet.CanvasLabel 1、开源地址 2、设置参数说明 二、组件集成 1、新建html文件 2、声明样式 3、定义矢量文本渲染器 4、定义地图 5、添加矢量数据 6、最终效果 总结 前言 在一般的业务场景中&#xff0c;针对小量的矢量数据&#xff0c;比如POI兴…

java现在就业环境怎么样?

java现在就业环境怎么样&#xff1f; 在开始前我有一些资料&#xff0c;是我根据网友给的问题精心整理了一份「java的资料从专业入门到高级教程」&#xff0c; 点个关注在评论区回复“888”之后私信回复“888”&#xff0c;全部无偿共享给大家&#xff01;&#xff01;&#…

运用多媒体辅助教学的好处

在教育信息化的大背景下&#xff0c;运用多媒体辅助教学已经成为了当今课堂的标配。但很多老师或许只是盲目地追求形式&#xff0c;而未能深入体会到其真正的价值。那么&#xff0c;运用多媒体辅助教学究竟有哪些好处呢&#xff1f; 多媒体辅助教学能够提起学生的学习兴趣。传统…

机械设计-哈工大课程学习-螺纹连接

圆柱螺纹主要几何参数螺纹参数 ①外径&#xff08;大径&#xff09;&#xff0c;与外螺纹牙顶或内螺纹牙底相重合的假想圆柱体直径。螺纹的公称直径即大径。 ②内径&#xff08;小径&#xff09;&#xff0c;与外螺纹牙底或内螺纹牙顶相重合的假想圆柱体直径。 ③中径&#xff…

Ubuntu重设root的密码

重设root的密码 未重设密码之前&#xff0c;Ubuntu 中默认的 root 密码是随机的&#xff0c;即每次开机都会有一个新的root 密码&#xff0c;所以此时的 root 用户密码并不确定&#xff1b; 重设root 密码&#xff0c;使用安装时创建的用户登录后sudo su切换至root用户&#…

MES管理系统为何成为汽配企业的刚需

随着经济全球化、产品定制化及安全法规的严格化&#xff0c;汽配企业的经营环境变得越来越复杂。中国劳动力资源和原辅料成本的持续上升&#xff0c;导致行业利润率不断下滑。为了应对这些挑战&#xff0c;汽配企业需要引入一种精益制造和管理的工具&#xff0c;而MES管理系统正…

gdb调试工具详解

一、知识铺垫 1、程序的发布方式有两种&#xff0c;debug模式和release模式 2、gcc/g形成的可执行程序默认是release模式 3、要使用gdb调试&#xff0c;必须在源代码生成可执行程序的时候, 加上 -g 选项 命令&#xff1a;gcc test.c -o test -g 很明显可以看出debug模式下的…

DBA技术栈MongoDB: 数据增改删除

该博文主要介绍mongoDB对文档数据的增加、更新、删除操作。 1.插入数据 以下案例演示了插入单个文档、多个文档、指定_id、指定多个索引以及插入大量文档的情况。在实际使用中&#xff0c;根据需求选择适合的插入方式。 案例1&#xff1a;插入单个文档 db.visitor.insert({…

深度解析 Compose 的 Modifier 原理 -- PointerInputModifier

" Jetpack Compose - - Modifier 系列文章 " &#x1f4d1; 《 深入解析 Compose 的 Modifier 原理 - - Modifier、CombinedModifier 》 &#x1f4d1; 《 深度解析 Compose 的 Modifier 原理 - - Modifier.composed()、ComposedModifier 》 &#x1f4d1; 《 深入解…

智慧文旅运营综合平台:重塑文化旅游产业的新引擎

目录 一、建设意义 二、包含内容 三、功能架构 四、典型案例 五、智慧文旅全套解决方案 - 210份下载 在数字化浪潮席卷全球的今天&#xff0c;智慧文旅运营综合平台作为文化旅游产业与信息技术深度融合的产物&#xff0c;正逐渐显现出其强大的生命力和广阔的发展前景。 该…

JAVA RPC Thrift基操实现与微服务间调用

一、Thrift 基操实现 1.1 thrift文件 namespace java com.zn.opit.thrift.helloworldservice HelloWorldService {string sayHello(1:string username) }1.2 执行命令生成Java文件 thrift -r --gen java helloworld.thrift生成代码HelloWorldService接口如下 /*** Autogene…