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

这篇具有很好参考价值的文章主要介绍了Spring Authorization Server入门 (二十) 实现二维码扫码登录。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

实现原理

  • 打开网页,发起授权申请/未登录被重定向到登录页面
  • 选择二维码登录,页面从后端请求二维码
  • 页面渲染二维码图片,并轮询请求,获取二维码的状态
  • 事先登录过APP的手机扫描二维码,然后APP请求服务器端的API接口,把用户认证信息传递到服务器中
  • 后端收到APP的请求后更改二维码状态,并把用户认证信息写入session
  • 页面得到扫码确认的响应,并跳转回之前未登录的地址

在这个流程中“用户认证信息写入session”后前端在重定向时能获取到认证信息是因为现在的认证服务引入了spring session data redis依赖,并且在application.yml中配置了server.servlet.session.cookie.domain: cdhttp.cn属性(演示环境域名),这里是指定spring session的顶级域名,在该域名下的子域名服务共享session,如果你是在开发阶段可以配置为server.servlet.session.cookie.domain: 127.0.0.1,这时候前端访问使用127.0.0.1:5173,认证服务使用127.0.0.1:8080,端口可以不一样,但是认证服务和前端必须使用同一域名。

代码实现

后端实现

引入二维码依赖

<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();

	@Override
    public QrCodeGenerateResponse generateQrCode() {
		// 生成二维码唯一id
		String qrCodeId = IdWorker.getIdStr();
		// 生成二维码并转为base64
		String pngQrCode = QrCodeUtil.generateAsBase64(qrCodeId, new QrConfig(), "png");
		QrCodeInfo info = QrCodeInfo.builder()
                .qrCodeId(qrCodeId)
                // 待扫描状态
                .qrCodeStatus(0)
                // 1分钟后过期
                .expiresTime(LocalDateTime.now().plusMinutes(2L))
                .build();

		// 获取当前request
		RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
		if (requestAttributes != null) {
			// 获取当前session
			HttpServletRequest 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 parameter
				String[] 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);
	}

	@Override
    public 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;
	}

	@Override
    public 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);
	}

	@Override
    public 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) {
				// 获取当前request
				RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
				if (requestAttributes == null) {
					throw new RuntimeException("获取当前Request失败.");
				}
				// 获取当前session
				HttpServletRequest 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.code
        captchaImage.value = result.data.imageData
        loginModel.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 = true
  loginModel.value.loginType = type
  loginSubmit(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.qrCodeId
      getQrCodeInfo.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.data
        if (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分支的二维码登录前端登录页面实现。

效果

Spring Authorization Server入门 (二十) 实现二维码扫码登录,Spring Authorization Server,Spring Security,Spring,spring,sql,数据库

附录

代码仓库:Gitee、Github

参考资料

SpringBoot二维码登录(中)
反向工程解析QQ扫码登录的OAuth2流程
聊一聊二维码扫描登录原理文章来源地址https://www.toymoban.com/news/detail-833184.html

到了这里,关于Spring Authorization Server入门 (二十) 实现二维码扫码登录的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 【spring authorization server系列教程】(一)入门系列,spring authorization server简介。快速构建一个授权服务器(基于最新版本0.3.0)

    【spring authorization server系列教程】(一)入门系列,快速构建一个授权服务器 spring authorization server是spring团队最新的认证授权服务器,之前的oauth2后面会逐步弃用。不过到现在发文的时候,我看到官网已经把之前oauth2仓库废弃了。 现在spring authorization server已经到生产就绪阶段了

    2024年02月05日
    浏览(36)
  • Spring Authorization Server入门 (十六) Spring Cloud Gateway对接认证服务

            之前虽然单独讲过Security Client和Resource Server的对接,但是都是基于Spring webmvc的,Gateway这种非阻塞式的网关是基于webflux的,对于集成Security相关内容略有不同,且涉及到代理其它微服务,所以会稍微比较麻烦些,今天就带大家来实现Gateway网关对接OAuth2认证服务。

    2024年02月10日
    浏览(45)
  • Spring Authorization Server入门 (十五) 分离授权确认与设备码校验页面

    2023-12-01修改:在session-data-redis(Github)分支中添加了基于 spring-session-data-redis 的实现,无需借助 nonceId 来保持认证状态,该分支已去除所有 nonceId 相关内容,需要注意的是 axios 在初始化时需要添加配置 withCredentials: true ,让请求携带cookie。当然一些响应json的处理还是使用下方的

    2024年02月14日
    浏览(26)
  • 【Spring Authorization Server 系列】(一)入门篇,快速搭建一个授权服务器

    官方主页:https://spring.io/projects/spring-authorization-server Spring Authorization Server 是一个框架,提供了 OAuth 2.1 和 OpenID Connect 1.0 规范以及其他相关规范的实现。 它建立在 Spring Security 之上,为构建 OpenID Connect 1.0 Identity Providers 和 OAuth2 Authorization Server 产品提供安全、轻量级和可定制

    2024年02月16日
    浏览(36)
  • Spring Authorization Server入门 (一) 初识SpringAuthorizationServer和OAuth2.1协议

    经过近些年网络和设备的不断发展,之前的oauth2.0发布的授权协议标准已经远远不能满足现在的场景和需求,根据其安全最佳实践,在oauth2.0的基础上移除了一些不安全的授权方式,并且对扩展协议进行整合。该协议定义了一系列关于授权的开放网络标准,允许用户授权第三方

    2024年02月11日
    浏览(23)
  • Spring Authorization Server入门 (三) 集成流程说明、细节补充和各种方式获取token测试

    在上一篇文章中的AuthorizationConfig.java配置类中,类上有三个注解,分别是@Configuration、@EnableWebSecurity和@EnableMethodSecurity注解,虽然在类中有注释,但是这里在细讲一下,同时放一下官网的说明 @EnableWebSecurity 加载了WebSecurityConfiguration配置类, 配置安全认证策略。 加载了Authenti

    2024年02月11日
    浏览(30)
  • Spring Security 6.x 系列【28】授权服务器篇之Spring Authorization Server 1.0 入门案例

    有道无术,术尚可求,有术无道,止于术。 本系列Spring Boot 版本 3.0.4 本系列Spring Security 版本 6.0.2 本系列Spring Authorization Server 版本 1.0.2 源码地址:https://gitee.com/pearl-organization/study-spring-security-demo 在前几篇文档中,我们学习了 OAuth 2.0 协议,并使用 spring-security-oauth2-client 完成

    2024年02月12日
    浏览(29)
  • Spring Authorization Server入门 (八) Spring Boot引入Security OAuth2 Client对接认证服务

    在之前的文章中实现了一个认证服务,并且添加了一些自定义的内容,现在暂时没想到认证服务的新内容,本篇文章就先写一下客户端对接的吧,水一篇。 当用户通过客户端去访问一个受限的资源时,客户端会检测是否有登录信息,没有登录信息会重定向至认证服务器去请求

    2024年02月21日
    浏览(37)
  • Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战

    项目源码 :youlai-mall 通过 Spring Cloud Gateway 访问认证中心进行认证并获取得到访问令牌。 再根据访问令牌 access_token 获取当前登录的用户信息。 Spring Security OAuth2 的最终版本是2.5.2,并于2022年6月5日正式宣布停止维护。Spring 官方为此推出了新的替代产品,即 Spring Authorization

    2024年02月04日
    浏览(29)
  • Spring Authorization Server 授权服务器

    Spring Authorization Server 遵循Oauth2.1和OpenID Connect 1.0,它建立在 Spring Security 之上。 要求JDK11以上 使用Idea创建一个Maven的Spring Boot(笔者使用的是 spring boot 2.7 )项目 pom需要引入 Authorization Server的配置 完整的pom.xml文件如下: 使用 @Bean 和 @Configuration 创建配置,这是官方推荐的最小配

    2024年02月16日
    浏览(29)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包