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

一、数据库设计

-- 用户表(支持双登录方式)
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 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)

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)

@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)

@Data
public class SocialUserInfo {
    private String openid;
    private String nickname;
    private String avatar;
    private String gender;
    private String provider;
}

4.3 登录请求封装类

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

4.4 注册请求封装类

@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)

@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)

@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)

@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)

@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)

@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)

@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)

@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)

<!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登录、微博登录等)。

posted @ 2025-11-27 09:08  佛祖让我来巡山  阅读(51)  评论(1)    收藏  举报

佛祖让我来巡山博客站 - 创建于 2018-08-15

开发工程师个人站,内容主要是网站开发方面的技术文章,大部分来自学习或工作,部分来源于网络,希望对大家有所帮助。

Bootstrap中文网