小明网站双登录系统实现——微信授权登录+用户名密码登录完整指南

以下是去除汉字间额外空格后的规范化版本:

一、数据库设计

sql 复制代码
-- 用户表(支持双登录方式)
CREATE TABLE `sys_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名(唯一)',
  `password` varchar(100) DEFAULT '' COMMENT '密码(本地登录用,第三方登录为空)',
  `real_name` varchar(50) DEFAULT '' COMMENT '真实姓名',
  `avatar` varchar(255) DEFAULT '' COMMENT '头像URL',
  `email` varchar(100) DEFAULT '' COMMENT '邮箱',
  `phone` varchar(20) DEFAULT '' COMMENT '手机号',
  `provider` varchar(20) NOT NULL COMMENT '登录方式(wechat/local)',
  `provider_id` varchar(100) DEFAULT NULL COMMENT '第三方用户唯一标识(微信openid)',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(0禁用,1启用)',
  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
  `refresh_token` varchar(255) DEFAULT NULL COMMENT '微信刷新令牌(AES加密存储)',
  `refresh_token_expire_time` datetime DEFAULT NULL COMMENT '刷新令牌过期时间',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`),
  UNIQUE KEY `uk_provider_openid` (`provider`,`provider_id`) COMMENT '第三方账号唯一索引',
  UNIQUE KEY `uk_phone` (`phone`),
  UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

二、POM依赖

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.15</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>dual-login-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
        <hutool.version>5.8.20</hutool.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.14</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

三、配置文件(application.yml)

yaml 复制代码
server:
  port: 8080
  servlet:
    context-path: /api

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dual_login_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root123
    driver-class-name: com.mysql.cj.jdbc.Driver

wechat:
  oauth:
    client-id: ${WECHAT_APP_ID:wx_your_app_id}
    client-secret: ${WECHAT_APP_SECRET:your_app_secret}
    redirect-uri: ${WECHAT_REDIRECT_URI:http://localhost:8080/api/auth/wechat/callback}
    auth-uri: https://open.weixin.qq.com/connect/qrconnect
    token-uri: https://api.weixin.qq.com/sns/oauth2/access_token
    user-info-uri: https://api.weixin.qq.com/sns/userinfo
    scope: snsapi_login
    token-expiration: 7200

jwt:
  secret: ${JWT_SECRET:your_strong_secret_key_32_chars_min}
  expiration: 86400000
  issuer: dual-login-system

crypto:
  aes:
    key: ${AES_SECRET_KEY:your_aes_secret_key_16_bytes}

logging:
  level:
    root: INFO
    com.example: DEBUG
  file:
    name: logs/dual-login.log

四、核心实体类

4.1 用户实体(SysUser.java)

java 复制代码
@Data
@TableName("sys_user")
public class SysUser {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String password;
    private String realName;
    private String avatar;
    private String email;
    private String phone;
    @TableField("provider")
    private String provider;
    @TableField("provider_id")
    private String providerId;
    private Integer status;
    private LocalDateTime lastLoginTime;
    @TableField("refresh_token")
    private String refreshToken;
    @TableField("refresh_token_expire_time")
    private LocalDateTime refreshTokenExpireTime;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;
}

4.2 社交用户信息(SocialUserInfo.java)

java 复制代码
@Data
public class SocialUserInfo {
    private String openid;
    private String nickname;
    private String avatar;
    private String gender;
    private String provider;
}

4.3 登录请求封装类

java 复制代码
@Data
public class LoginRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;
    @NotBlank(message = "密码不能为空")
    private String password;
}

4.4 注册请求封装类

java 复制代码
@Data
public class RegisterRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;
    @NotBlank(message = "密码不能为空")
    private String password;
    @NotBlank(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
    @Email(message = "邮箱格式不正确")
    private String email;
}

五、工具类

5.1 JWT工具类(JwtUtils.java)

java 复制代码
@Component
public class JwtUtils {
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private long expiration;
    @Value("${jwt.issuer}")
    private String issuer;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userDetails.getUsername());
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setIssuer(issuer)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS512)
                .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
}

5.2 加密工具类(CryptoUtils.java)

java 复制代码
@Component
public class CryptoUtils {
    private final AES aes;

    public CryptoUtils(@Value("${crypto.aes.key}") String aesKey) {
        if (aesKey.length() < 16) {
            aesKey = String.format("%-16s", aesKey).substring(0, 16);
        } else if (aesKey.length() > 16) {
            aesKey = aesKey.substring(0, 16);
        }
        this.aes = SecureUtil.aes(aesKey.getBytes());
    }

    public String encrypt(String data) {
        return aes.encryptHex(data);
    }

    public String decrypt(String encryptedData) {
        return aes.decryptStr(encryptedData);
    }
}

六、服务层

6.1 用户服务(UserService.java)

java 复制代码
@Service
@RequiredArgsConstructor
public class UserService extends ServiceImpl<SysUserMapper, SysUser> {
    private final PasswordEncoder passwordEncoder;
    private final CryptoUtils cryptoUtils;
    private final WechatAuthService wechatAuthService;

    public SysUser loginWithPassword(String username, String password) {
        SysUser user = findByUsername(username);
        if (user == null) throw new RuntimeException("用户不存在");
        if (!passwordEncoder.matches(password, user.getPassword())) throw new RuntimeException("密码错误");
        if (user.getStatus() != 1) throw new RuntimeException("账户已被禁用");
        user.setLastLoginTime(LocalDateTime.now());
        updateById(user);
        return user;
    }

    @Transactional
    public SysUser register(RegisterRequest request) {
        if (findByUsername(request.getUsername()) != null) throw new RuntimeException("用户名已存在");
        if (findByPhone(request.getPhone()) != null) throw new RuntimeException("手机号已注册");
        if (StrUtil.isNotBlank(request.getEmail()) && findByEmail(request.getEmail()) != null) throw new RuntimeException("邮箱已注册");
        
        SysUser user = new SysUser();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setPhone(request.getPhone());
        user.setEmail(request.getEmail());
        user.setRealName(request.getUsername());
        user.setProvider("local");
        user.setProviderId(null);
        user.setStatus(1);
        user.setCreatedAt(LocalDateTime.now());
        user.setUpdatedAt(LocalDateTime.now());
        save(user);
        return user;
    }

    @Transactional
    public SysUser findOrCreateByWechatInfo(SocialUserInfo socialInfo, WechatAuthService.TokenDTO tokenDTO) {
        SysUser user = lambdaQuery()
                .eq(SysUser::getProvider, "wechat")
                .eq(SysUser::getProviderId, socialInfo.getOpenid())
                .one();
        
        if (user != null) {
            user.setLastLoginTime(LocalDateTime.now());
            user.setRefreshToken(cryptoUtils.encrypt(tokenDTO.getRefreshToken()));
            user.setRefreshTokenExpireTime(calculateExpireTime(tokenDTO.getExpiresIn()));
            updateById(user);
            return user;
        }
        
        user = new SysUser();
        user.setUsername(generateUniqueUsername(socialInfo.getNickname()));
        user.setPassword("");
        user.setRealName(socialInfo.getNickname());
        user.setAvatar(socialInfo.getAvatar());
        user.setProvider("wechat");
        user.setProviderId(socialInfo.getOpenid());
        user.setStatus(1);
        user.setRefreshToken(cryptoUtils.encrypt(tokenDTO.getRefreshToken()));
        user.setRefreshTokenExpireTime(calculateExpireTime(tokenDTO.getExpiresIn()));
        user.setCreatedAt(LocalDateTime.now());
        user.setUpdatedAt(LocalDateTime.now());
        save(user);
        return user;
    }

    // 辅助方法省略...
}

6.2 微信授权服务(WechatAuthService.java)

java 复制代码
@Service
@RequiredArgsConstructor
public class WechatAuthService {
    private final WechatOAuthProperties wechatProps;

    public String buildAuthUrl(String state) {
        return UriComponentsBuilder.fromHttpUrl(wechatProps.getAuthUri())
                .queryParam("appid", wechatProps.getClientId())
                .queryParam("redirect_uri", wechatProps.getRedirectUri())
                .queryParam("response_type", "code")
                .queryParam("scope", wechatProps.getScope())
                .queryParam("state", state)
                .fragment("wechat_redirect")
                .build().toUriString();
    }

    public TokenDTO getAccessToken(String code) {
        String url = UriComponentsBuilder.fromHttpUrl(wechatProps.getTokenUri())
                .queryParam("appid", wechatProps.getClientId())
                .queryParam("secret", wechatProps.getClientSecret())
                .queryParam("code", code)
                .queryParam("grant_type", "authorization_code")
                .build().toUriString();
        String response = HttpUtil.get(url);
        JSONObject json = JSONUtil.parseObj(response);
        if (json.containsKey("errcode")) throw new RuntimeException("微信授权失败:" + json.getStr("errmsg"));
        
        TokenDTO tokenDTO = new TokenDTO();
        tokenDTO.setAccessToken(json.getStr("access_token"));
        tokenDTO.setRefreshToken(json.getStr("refresh_token"));
        tokenDTO.setOpenid(json.getStr("openid"));
        tokenDTO.setExpiresIn(json.getInt("expires_in", 7200));
        tokenDTO.setScope(json.getStr("scope"));
        return tokenDTO;
    }
    
    // 其他方法省略...
}

七、控制器(AuthController.java)

java 复制代码
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final UserService userService;
    private final WechatAuthService wechatAuthService;
    private final JwtUtils jwtUtils;
    private final CryptoUtils cryptoUtils;

    @PostMapping("/login")
    public Result<LoginResult> login(@RequestBody LoginRequest request) {
        SysUser user = userService.loginWithPassword(request.getUsername(), request.getPassword());
        UserDetails userDetails = new User(user.getUsername(), user.getPassword(), Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
        String token = jwtUtils.generateToken(userDetails);
        LoginResult result = new LoginResult();
        result.setToken(token);
        result.setUserId(user.getId());
        result.setUsername(user.getUsername());
        result.setAvatar(user.getAvatar());
        result.setProvider(user.getProvider());
        return Result.success(result);
    }

    @GetMapping("/wechat/login")
    public void wechatLogin(HttpServletResponse response, HttpSession session) throws IOException {
        String state = UUID.randomUUID().toString();
        session.setAttribute("wechat_oauth_state", state);
        String authUrl = wechatAuthService.buildAuthUrl(state);
        response.sendRedirect(authUrl);
    }

    @GetMapping("/wechat/callback")
    public ModelAndView wechatCallback(@RequestParam String code, @RequestParam String state, HttpSession session) {
        String savedState = (String) session.getAttribute("wechat_oauth_state");
        if (savedState == null || !savedState.equals(state)) {
            return new ModelAndView("redirect:/login?error=invalid_state");
        }
        try {
            WechatAuthService.TokenDTO tokenDTO = wechatAuthService.getAccessToken(code);
            SocialUserInfo userInfo = wechatAuthService.getUserInfo(tokenDTO.getAccessToken(), tokenDTO.getOpenid());
            SysUser user = userService.findOrCreateByWechatInfo(userInfo, tokenDTO);
            UserDetails userDetails = new User(user.getUsername(), "", Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
            String token = jwtUtils.generateToken(userDetails);
            return new ModelAndView("redirect:https://yourfrontend.com/login/success?token=" + token);
        } catch (Exception e) {
            return new ModelAndView("redirect:/login?error=" + e.getMessage());
        } finally {
            session.removeAttribute("wechat_oauth_state");
        }
    }
}

八、Spring Security配置(SecurityConfig.java)

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
                .antMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}

九、JWT认证过滤器(JwtAuthenticationFilter.java)

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtils jwtUtils;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtUtils jwtUtils, UserDetailsService userDetailsService) {
        this.jwtUtils = jwtUtils;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (jwt != null) {
                String username = jwtUtils.extractUsername(jwt);
                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    if (jwtUtils.validateToken(jwt, userDetails)) {
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
        } catch (Exception e) {
            logger.error("无法设置用户认证: {}", e);
        }
        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        if (headerAuth != null && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }
}

十、前端实现(简化版)

10.1 登录页面(login.html)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>双登录系统</title>
    <style>
        /* 样式保持不变 */
    </style>
</head>
<body>
    <h2>欢迎登录</h2>
    <div id="password-login-form">
        <div class="form-group">
            <label for="username">用户名</label>
            <input type="text" id="username" placeholder="请输入用户名">
        </div>
        <div class="form-group">
            <label for="password">密码</label>
            <input type="password" id="password" placeholder="请输入密码">
        </div>
        <button onclick="loginWithPassword()">登录</button>
    </div>
    
    <div class="divider"><span>或</span></div>
    
    <div class="social-login">
        <button class="social-btn wechat-btn" onclick="loginWithWechat()">微信登录</button>
    </div>
    
    <div style="margin-top: 20px; text-align: center;">
        <span>还没有账号?</span>
        <a href="#" onclick="showRegisterForm()">立即注册</a>
    </div>
    
    <div id="register-form" style="display: none; margin-top: 20px;">
        <!-- 注册表单内容 -->
    </div>

    <script>
        // JavaScript函数保持不变
    </script>
</body>
</html>

十一、生产环境部署建议

  1. 安全增强

    • 使用HTTPS加密传输
    • 配置防火墙规则
    • 定期更换JWT密钥和AES密钥
    • 实现接口访问频率限制
  2. 监控与日志

    • 集成ELK收集日志
    • 配置Prometheus+Grafana监控
    • 记录关键操作日志(登录、注册、权限变更)
  3. 高可用架构

    • 使用Nginx做负载均衡
    • 数据库主从复制
    • Redis缓存热点数据
    • 微服务化拆分(用户服务、认证服务)
  4. 用户体验优化

    • 实现记住登录状态功能
    • 添加图形验证码防刷
    • 支持账号绑定/解绑功能
    • 提供忘记密码重置功能

十二、总结

通过本文实现的双登录系统,小明网站同时支持了微信授权登录和用户名密码登录两种方式,并且:

  1. 共享用户体系 :两种登录方式使用同一套用户表,通过provider字段区分
  2. 完整的注册流程:支持用户名、密码、手机号、邮箱注册
  3. 安全的认证机制:使用JWT进行认证,Spring Security保护接口
  4. 生产级实现:包含错误处理、日志记录、加密存储等生产环境必要功能

这个系统可以直接应用于生产环境,也可以作为基础框架扩展其他登录方式(如QQ登录、微博登录等)。

相关推荐
泽济天下12 天前
【经验分享】基于Spring Boot 4.0快速实现最简版的OAuth2 Server和Client
spring boot·springboot·oauth2
佛祖让我来巡山19 天前
Spring Security 鉴权流程与过滤器链深度剖析
springsecurity·authenticationmanager
佛祖让我来巡山20 天前
小明网站微信登录改造记——OAuth2完整指南(含续期逻辑)
oauth2·微信授权登录
佛祖让我来巡山20 天前
大型项目基于Spring Security的登录鉴权与数据权限控制完整方案
springsecurity·保姆级鉴权·大型项目登录认证
佛祖让我来巡山20 天前
Spring Security前后端分离接入流程保姆级教程
权限校验·springsecurity·登录认证
佛祖让我来巡山20 天前
Spring Security 认证流程闭环与调用链路详解
springsecurity·authenticationmanager
佛祖让我来巡山21 天前
小明的Spring Security入门到深入实战
springsecurity
佛祖让我来巡山22 天前
⚠️登录认证功能的成长过程:整体概述
安全·登录·springsecurity·登录认证·认证授权
不会吃萝卜的兔子1 个月前
spring - 微服务授权 2 实战
spring·oauth2·authorization