本文将介绍如何实现手机和邮箱登录功能。我们在现有的用户名/密码登录基础上,扩展了手机号验证码和邮箱验证码两种登录方式。用户登录时可以选择任一方式,系统会相应发送验证码到用户手机或邮箱。用户在登录界面输入验证码后,系统会进行校验,通过后生成身份认证token供后续访问使用。

一、增加自定义授权模式

在实现手机号码和邮箱登录功能时,我们首先需要解决的是授权模式的扩展问题。OpenIddict作为一个强大的认证授权框架,虽然默认支持多种标准的OAuth2.0授权模式,但并不直接支持手机号码和邮箱验证码登录这样的自定义认证方式。这就需要我们对OpenIddict进行扩展,以支持这些额外的认证场景。

扩展授权模式的实现方式非常直接。我们需要在应用程序的OpenIddict配置中,也就是在OpenIddictServiceExtensions扩展类的AddServer方法中,通过调用options.AllowCustomFlow("sms_otp").AllowCustomFlow("email_code");来注册这两个自定义的授权流程。其中,sms_otp流程将用于处理手机短信验证码登录,而email_code流程则负责邮箱验证码登录的实现。

这种扩展方式的优势是,它完全遵循了OAuth2.0的框架设计理念,同时又提供了足够的灵活性。通过这样的自定义授权模式,我们可以在保持原有OAuth2.0标准授权流程的基础上,无缝地集成短信验证码和邮箱验证码这两种更适合移动应用场景的认证方式。这不仅提升了系统的安全性,还为用户提供了更多样化的登录选择。

二、Service 实现短信/邮箱验证码登录

在 Service 中实现短信/邮箱验证码登录的逻辑。首先在接口IAuthorizationService中新增两个方法LoginBySmSCodeAsyncLoginByEmailCodeAsync,这两个方法分别用来实现短信和邮箱验证登录,接口代码如下:

/// <summary>
/// 短信验证登录
/// </summary>
/// <param name="phoneNumber"></param>
/// <param name="code"></param>
/// <param name="scopes"></param>
/// <returns>ClaimsPrincipal</returns>
Task<ClaimsPrincipal> LoginBySmSCodeAsync(string phoneNumber, string code, ImmutableArray<string> scopes);

/// <summary>
/// 邮箱验证码登录
/// </summary>
/// <param name="email"></param>
/// <param name="code"></param>
/// <param name="scopes"></param>
/// <returns>ClaimsPrincipal</returns>
Task<ClaimsPrincipal> LoginByEmailCodeAsync(string email, string code, ImmutableArray<string> scopes);

上述代码虽然看起来简单直观,但其中包含了完整的验证码登录流程实现。在AuthorizationServiceImpl类中,我们需要实现这两个关键的验证方法。这两个方法的核心逻辑基本相同,都遵循一个统一的验证流程:首先对用户提供的验证码(code)进行验证,确保验证码的有效性和正确性;接着系统会检查用户信息是否存在于数据库中,如果用户确实存在,则进入下一步;最后,系统会为该用户生成一个包含必要身份信息的认证token并返回给客户端。具体实现代码如下:

/// <summary>
/// 短信验证登录
/// </summary>
/// <param name="phoneNumber"></param>
/// <param name="code"></param>
/// <param name="scopes"></param>
/// <returns>ClaimsPrincipal</returns>
public async Task<ClaimsPrincipal> LoginBySmSCodeAsync(string phoneNumber, string code,
    ImmutableArray<string> scopes)
{
    if (string.IsNullOrEmpty(phoneNumber) || string.IsNullOrEmpty(code))
    {
        throw new BusinessException("手机号或验证码不能为空");
    }

    // 验证短信验证码
    bool isOk = await _smsService.VerifyCodeAsync(phoneNumber, SmSPurposeEnum.Login, code);
    if (!isOk)
    {
        throw new BusinessException("验证码错误");
    }

    // 查找用户
    var user = await _userManager.Users.FirstOrDefaultAsync(u => u.PhoneNumber == phoneNumber);
    if (user == null)
    {
        throw new BusinessException("用户不存在");
    }

    return await BuildPrincipalAsync(user, scopes);
}

/// <summary>
/// 邮箱验证码登录
/// </summary>
/// <param name="email"></param>
/// <param name="code"></param>
/// <param name="scopes"></param>
/// <returns>ClaimsPrincipal</returns>
public async Task<ClaimsPrincipal> LoginByEmailCodeAsync(string email, string code, ImmutableArray<string> scopes)
{
    if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(code))
    {
        throw new BusinessException("邮箱或验证码不能为空");
    }

    var redisCode = await _redis.GetStringAsync(email);
    if (string.IsNullOrEmpty(redisCode))
    {
        throw new BusinessException("验证码已过期或不存在");
    }

    if (redisCode != code.Trim())
    {
        throw new BusinessException("验证码错误");
    }

    // 查找用户
    var user = await _userManager.FindByEmailAsync(email);
    if (user == null)
    {
        throw new BusinessException("用户不存在");
    }

    await _redis.RemoveAsync(email);
    return await BuildPrincipalAsync(user, scopes);
}

/// <summary>
/// 通用生成ClaimsPrincipal方法
/// </summary>
/// <param name="user"></param>
/// <param name="scopes"></param>
/// <returns>ClaimsPrincipal</returns>
private async Task<ClaimsPrincipal> BuildPrincipalAsync(SpUser user, ImmutableArray<string> scopes)
{
    var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    identity.AddClaim(OpenIddictConstants.Claims.Subject, await _userManager.GetUserIdAsync(user));
    identity.AddClaim(OpenIddictConstants.Claims.Name, user.UserName ?? string.Empty);
    if (!string.IsNullOrWhiteSpace(user.Email))
    {
        identity.AddClaim(OpenIddictConstants.Claims.Email, user.Email);
    }

    identity.AddClaim(OpenIddictConstants.Claims.Audience, "api");

    foreach (var role in await _userManager.GetRolesAsync(user))
    {
        identity.AddClaim(ClaimTypes.Role, role);
    }

    identity.SetDestinations(static claim => claim.Type switch
    {
        OpenIddictConstants.Claims.Name when claim.Subject.HasScope(OpenIddictConstants.Permissions.Scopes.Profile)
            => [OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken],
        OpenIddictConstants.Claims.Email when claim.Subject.HasScope(OpenIddictConstants.Permissions.Scopes.Email)
            => [OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken],
        ClaimTypes.Role => [OpenIddictConstants.Destinations.AccessToken],
        _ => [OpenIddictConstants.Destinations.AccessToken]
    });

    var principal = new ClaimsPrincipal(identity);

    var validScopes = new[] { "api", OpenIddictConstants.Scopes.OfflineAccess };
    var filtered = scopes.Intersect(validScopes).ToList();
    principal.SetScopes(filtered.Any() ? filtered : new[] { "api" });

    // 可根据角色/设备等动态设置生命周期(此处使用默认)
    principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(30));
    principal.SetRefreshTokenLifetime(TimeSpan.FromDays(14));

    return principal;
}

在上述代码实现中,我们构建了一个完整的验证码登录体系。其核心是通过封装统一的BuildPrincipalAsync方法来生成ClaimsPrincipal对象,该方法负责处理用户身份信息的构建和授权范围的设置。这个方法首先创建一个基于OpenIddict认证方案的ClaimsIdentity,然后向其中添加用户的基本信息声明,包括用户ID、用户名、邮箱等。同时,它还会加入用户的角色信息,并通过SetDestinations方法为每个声明指定其可用范围。

在此基础上,我们实现了两种验证码登录方式:LoginByEmailCodeAsyncLoginBySmSCodeAsync。邮箱验证码登录通过Redis进行验证码的存储和校验,而短信验证码登录则依赖于专门的短信服务进行验证。这两个方法都遵循相似的业务流程:首先验证用户输入的验证码是否正确,然后查找对应的用户信息,最后调用BuildPrincipalAsync方法生成包含完整身份信息的ClaimsPrincipal对象。

三、AuthorizationController 实现短信/邮箱验证码登录API是手机号找回。

在ResetEnum枚举中,我们定义了Email和Phone两个选项,分别对应邮箱找回和手机号找回两种方式。这个枚举的设计简单明了,能清晰地表达业务含义。

在AuthorizationServiceImpl中的ResetPasswordAsync方法实现了具体的密码重置逻辑。方法首先验证传入参数的有效性,然后根据ResetBy的值走不同的验证流程。如果是手机号找回,就调用短信服务验证验证码;如果是邮箱找回,则从Redis中获取之前存储的验证码进行验证。验证通过后,方法会查找对应的用户,并使用ASP.NET Core Identity框架提供的方法重置密码。整个实现既保证了安全性,又提供了良好的用户体验。

这套密码重置机制的设计考虑周全,既支持多种找回方式,又实现了必要的安全验证,是一个典型的企业级应用密码找回解决方案。
在这一小节,我们将修改AuthorizationController控制器中的GetToken() Action 使其支持短信/邮箱验证码登录。代码如下:

public async Task<ActionResult> GetToken()
{
    // more code ...

    // 短信验证码登录
    if (string.Equals(request.GrantType, "sms_otp", StringComparison.Ordinal))
    {
        var phoneNumber = (string?)request.GetParameter("phone_number");
        var code = (string?)request.GetParameter("code");
        if (string.IsNullOrEmpty(phoneNumber) || string.IsNullOrEmpty(code))
        {
            throw new BusinessException("手机号或验证码不能为空");
        }

        var principal =
            await _authorizationService.LoginBySmSCodeAsync(phoneNumber, code,
                request.GetScopes());
        var signInResult = SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        return signInResult;
    }
    
    // 邮箱验证码登录
    if (string.Equals(request.GrantType, "email_code", StringComparison.Ordinal))
    {
        var email = (string?)request.GetParameter("email");
        var code = (string?)request.GetParameter("code");
        if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(code))
        {
            throw new BusinessException("邮箱或验证码不能为空");
        }

        var principal =
            await _authorizationService.LoginByEmailCodeAsync(email, code,
                request.GetScopes());
        var signInResult = SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        return signInResult;
    }

    // more code ...
}

首先,通过判断请求中的grant_type参数来识别具体的登录方式。当grant_type为"sms_otp"时表示短信验证码登录,为"email_code"时表示邮箱验证码登录。

对于短信验证码登录,系统会从请求参数中获取phone_number(手机号)和code(验证码)。系统会先验证这两个参数是否为空,如果为空则抛出业务异常。验证通过后,调用_authorizationService.LoginBySmSCodeAsync方法进行实际的登录验证,该方法会验证短信验证码的正确性并查找对应的用户信息。邮箱验证码登录的处理方式类似,从请求参数中获取email和code,进行空值检查后调用_authorizationService.LoginByEmailCodeAsync方法进行验证和用户查找。两种登录方式在验证成功后都会得到一个包含用户身份信息的ClaimsPrincipal对象。最后通过SignIn方法使用OpenIddict的认证方案创建登录会话,并返回包含访问令牌的响应。

三、总结

本文详细介绍了如何在.NET 8项目中实现手机号和邮箱验证码登录功能。通过扩展OpenIddict框架的授权模式,我们成功实现了sms_otp和email_code两种自定义的授权流程。在Service层,我们封装了验证码校验、用户查找和身份信息构建等核心逻辑,确保了登录流程的安全性和可靠性。同时,在Controller层通过统一的Token获取接口,集成了这两种登录方式。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐