认证服务------功能实现逻辑

认证服务分为两种,一种是社交登录,一种是单点登录,都交给认证中心(OAuth2.0)来处理,用一个专门的服务来作这个认证中心,所有的登录注册都是由这个认证中心来处理的,由于注册要发短信,短信又是调用了阿里云的接口, 这个发验证码短信的组件放到了第三方服务中,所以又需要调用第三方服务来发短信,最终的登录注册实现是写在用户服务里的,所以还需要远程调用用户服务

单点登录注册

一、用户注册功能(登录和注册都是以表单提交发的请求)

1、实现发送手机验证码功能(手机验证码是存到Redis缓存中)

(1)前端实现发送短信后倒计时效果和发送请求/sms/sendCode给后端处理

		<div class="register-box">
			<label class="other_label">验 证 码:
				<input name="code" maxlength="20" type="text"  class="caa">
			</label>
			<a id="sendCode" class=""> 发送验证码 </a>
		</div>
	/**
	 * 发送验证码请求给后端
	 */
	$(function () {
    	$("#sendCode").click(function () {
			//2、倒计时
			//只有class里有disabled就表示还在倒计时中,什么都不干(用于防止在倒计时期间重复发送验证码)
			if($(this).hasClass("disabled")) {
				//正在倒计时中
			} else {
				//1、给指定手机号发送验证码
				$.get("/sms/sendCode?phone=" + $("#phoneNum").val(),function (data) {
					if(data.code != 0) {
						alert(data.msg);
					}
				});
				timeoutChangeStyle();
			}
		});
    });

	/**
	 * 实现验证码倒计时效果
	 * @type {number}
	 */
    var num = 60; 	//倒计时60s
    function timeoutChangeStyle() {
    	//点击发送验证码后就设置其class为disabled(用于防止在倒计时期间重复发送验证码)
    	$("#sendCode").attr("class","disabled");
    	//倒计时结束
    	if(num == 0) {
			$("#sendCode").text("发送验证码");
			num = 60;
			//清空前面加的class
			$("#sendCode").attr("class","");
		} else {
			var str = num + "s 后再次发送";
			$("#sendCode").text(str);
			//1s后再调用自身方法来实现倒计时
			setTimeout("timeoutChangeStyle()",1000);
		}
		num --;
	}

(2)在认证服务的LoginController来处理请求/sms/sendCode(发短信验证码)

分流程:(其实就是把短信验证码以下面的格式存到缓存再真正发送短信验证码

Redis缓存中验证码的格式sms:code:123456789->123456_1646981054661 ,123456789是手机号,123456表示验证码,1646981054661表示存入缓存的时间

1>判断Redis缓存里有没有这个手机号对应的验证码信息

2>如果Redis缓存中有这个验证码信息那就要判断时间合法性(其实就是验证码有没有过期)

3>创建验证码并且拼装成存入Redis缓存中的value值格式

4>调用第三方服务来真正发送验证码

/**
* 短信验证码
* @param phone
* @return
*/
@ResponseBody
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
    
    /**
    *  接口防刷
    */
    
    //把验证码从缓存中提取出来
    //(缓存中验证码的格式sms:code:123456789->123456_1646981054661 ,123456789是手机号,123456表示验证码,1646981054661表示存入缓存的时间)
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
    if (!StringUtils.isEmpty(redisCode)) {
        //把存入缓存的验证码的值给提取出来(格式是123456_1646981054661 ,123456表示验证码,1646981054661表示存入缓存的时间)
        long currentTime = Long.parseLong(redisCode.split("_")[1]);
        //活动存入redis的时间,用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码
        if (System.currentTimeMillis() - currentTime < 60000) {
            //60s内不能再发
            // BizCodeEnum.SMS_CODE_EXCEPTION=10002     BizCodeEnum.SMS_CODE_EXCEPTION = 10002
            return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(),BizCodeEnum.SMS_CODE_EXCEPTION.getMessage());
        }
    }
    
    /**
    * 2、创建验证码存入redis.存key-phone,value-code
    */
    int code = (int) ((Math.random() * 9 + 1) * 100000);
    String codeNum = String.valueOf(code);
    //存入缓存的验证码格式是123456_1646981054661  加系统时间是为了防止多次刷新验证码
    String redisStorage = codeNum + "_" + System.currentTimeMillis();
    
    //存入redis,防止同一个手机号在60秒内再次发送验证码(存入缓存的格式sms:code:123456789->123456 ,其中123456789是手机号,123456是验证码)
    stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,
                                          redisStorage,10, TimeUnit.MINUTES);//AuthServerConstant.SMS_CODE_CACHE_PREFIX = sms:code:
    
    //        String codeNum = UUID.randomUUID().toString().substring(0,6);
    thirdPartFeignService.sendCode(phone, codeNum);
    
    return R.ok();
    }

注意:这里的短信验证码不是第三方服务发送的,具体短信验证码的内容是上面的sendCode方法随机生成的,这个验证码甚至可以自定义为666666,所以这个验证码的内容在上面sendCode方法就已经存到Redis缓存中,方便后面进行验证码校验

(3)远程调用第三方服务来真正发送验证码

package com.saodai.saodaimall.auth.feign;


import com.saodai.common.utils.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * 远程调用第三方服务来发送验证码
 **/

@FeignClient("saodaimall-third-party")
public interface ThirdPartFeignService {

    @GetMapping(value = "/sms/sendCode")
    R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);

}
package com.saodai.saodaimall.thirdparty.controller;


import com.saodai.common.utils.R;
import com.saodai.saodaimall.thirdparty.component.SmsComponent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * 短信验证码的控制器
 **/

@RestController
@RequestMapping(value = "/sms")
public class SmsSendController {

    @Resource
    private SmsComponent smsComponent;

    /**
     * 提供给别的服务进行调用
     * @param phone
     * @param code
     * @return
     */
    @GetMapping(value = "/sendCode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {

        //发送验证码
        smsComponent.sendCode(phone,code);

        return R.ok();
    }

}
package com.saodai.saodaimall.thirdparty.component;


import com.saodai.saodaimall.thirdparty.util.HttpUtils;
import lombok.Data;
import org.apache.http.HttpResponse;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 *  短信验证码发送的组件类(提供发送短信验证码的接口)
 **/

@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {

    private String host;
    private String path;
    private String appcode;

    public void sendCode(String phone,String code) {
        String method = "POST";
        Map<String, String> headers = new HashMap<String, String>();
        //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
        headers.put("Authorization", "APPCODE " + appcode);
        //根据API的要求,定义相对应的Content-Type
        headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
        Map<String, String> querys = new HashMap<String, String>();
        Map<String, String> bodys = new HashMap<String, String>();
        //注意这里的"code:"是必须要有的,正确的格式是"code:6666",没加code:就会报400错误,expire_at:6表示验证码有效时间为6分钟
        bodys.put("content", "code:"+code+",expire_at:6");
        bodys.put("phone_number", phone);
        //使用的是自定义的模板(content需要两个参数)
        bodys.put("template_id", "TPL_09229");



        try {
            HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);
            System.out.println(response.toString());
            //获取response的body
            //System.out.println(EntityUtils.toString(response.getEntity()));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}
package com.saodai.saodaimall.thirdparty.util;

import org.apache.commons.lang.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* 阿里云短信验证码接口要用的工具类
*/
public class HttpUtils {
    
    /**
    * get
    * 
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @return
    * @throws Exception
    */
    public static HttpResponse doGet(String host, String path, String method, 
                                     Map<String, String> headers, 
                                     Map<String, String> querys)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpGet request = new HttpGet(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        return httpClient.execute(request);
    }
    
    /**
    * post form
    * 
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @param bodys
    * @return
    * @throws Exception
    */
    public static HttpResponse doPost(String host, String path, String method, 
                                      Map<String, String> headers, 
                                      Map<String, String> querys, 
                                      Map<String, String> bodys)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        if (bodys != null) {
            List<NameValuePair> nameValuePairList = new ArrayList<NameValuePair>();
            
            for (String key : bodys.keySet()) {
                nameValuePairList.add(new BasicNameValuePair(key, bodys.get(key)));
            }
            UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(nameValuePairList, "utf-8");
            formEntity.setContentType("application/x-www-form-urlencoded; charset=UTF-8");
            request.setEntity(formEntity);
        }
        
        return httpClient.execute(request);
    }	
    
    /**
    * Post String
    * 
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @param body
    * @return
    * @throws Exception
    */
    public static HttpResponse doPost(String host, String path, String method, 
                                      Map<String, String> headers, 
                                      Map<String, String> querys, 
                                      String body)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        if (StringUtils.isNotBlank(body)) {
            request.setEntity(new StringEntity(body, "utf-8"));
        }
        
        return httpClient.execute(request);
    }
    
    /**
    * Post stream
    * 
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @param body
    * @return
    * @throws Exception
    */
    public static HttpResponse doPost(String host, String path, String method, 
                                      Map<String, String> headers, 
                                      Map<String, String> querys, 
                                      byte[] body)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        if (body != null) {
            request.setEntity(new ByteArrayEntity(body));
        }
        
        return httpClient.execute(request);
    }
    
    /**
    * Put String
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @param body
    * @return
    * @throws Exception
    */
    public static HttpResponse doPut(String host, String path, String method, 
                                     Map<String, String> headers, 
                                     Map<String, String> querys, 
                                     String body)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpPut request = new HttpPut(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        if (StringUtils.isNotBlank(body)) {
            request.setEntity(new StringEntity(body, "utf-8"));
        }
        
        return httpClient.execute(request);
    }
    
    /**
    * Put stream
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @param body
    * @return
    * @throws Exception
    */
    public static HttpResponse doPut(String host, String path, String method, 
                                     Map<String, String> headers, 
                                     Map<String, String> querys, 
                                     byte[] body)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpPut request = new HttpPut(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        if (body != null) {
            request.setEntity(new ByteArrayEntity(body));
        }
        
        return httpClient.execute(request);
    }
    
    /**
    * Delete
    *  
    * @param host
    * @param path
    * @param method
    * @param headers
    * @param querys
    * @return
    * @throws Exception
    */
    public static HttpResponse doDelete(String host, String path, String method, 
                                        Map<String, String> headers, 
                                        Map<String, String> querys)
        throws Exception {    	
        HttpClient httpClient = wrapClient(host);
        
        HttpDelete request = new HttpDelete(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }
        
        return httpClient.execute(request);
    }
    
    private static String buildUrl(String host, String path, Map<String, String> querys) throws UnsupportedEncodingException {
        StringBuilder sbUrl = new StringBuilder();
        sbUrl.append(host);
        if (!StringUtils.isBlank(path)) {
            sbUrl.append(path);
        }
        if (null != querys) {
            StringBuilder sbQuery = new StringBuilder();
            for (Map.Entry<String, String> query : querys.entrySet()) {
                if (0 < sbQuery.length()) {
                    sbQuery.append("&");
                }
                if (StringUtils.isBlank(query.getKey()) && !StringUtils.isBlank(query.getValue())) {
                    sbQuery.append(query.getValue());
                }
                if (!StringUtils.isBlank(query.getKey())) {
                    sbQuery.append(query.getKey());
                    if (!StringUtils.isBlank(query.getValue())) {
                        sbQuery.append("=");
                        sbQuery.append(URLEncoder.encode(query.getValue(), "utf-8"));
                    }        			
                }
            }
            if (0 < sbQuery.length()) {
                sbUrl.append("?").append(sbQuery);
            }
        }
        
        return sbUrl.toString();
    }
    
    private static HttpClient wrapClient(String host) {
        HttpClient httpClient = new DefaultHttpClient();
        if (host.startsWith("https://")) {
            sslClient(httpClient);
        }
        
        return httpClient;
    }
    
    private static void sslClient(HttpClient httpClient) {
        try {
            SSLContext ctx = SSLContext.getInstance("TLS");
            X509TrustManager tm = new X509TrustManager() {
                public X509Certificate[] getAcceptedIssuers() {
                    return null;
                }
                public void checkClientTrusted(X509Certificate[] xcs, String str) {
                    
                }
                public void checkServerTrusted(X509Certificate[] xcs, String str) {
                    
                }
            };
            ctx.init(null, new TrustManager[] { tm }, null);
            SSLSocketFactory ssf = new SSLSocketFactory(ctx);
            ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
            ClientConnectionManager ccm = httpClient.getConnectionManager();
            SchemeRegistry registry = ccm.getSchemeRegistry();
            registry.register(new Scheme("https", 443, ssf));
        } catch (KeyManagementException ex) {
            throw new RuntimeException(ex);
        } catch (NoSuchAlgorithmException ex) {
            throw new RuntimeException(ex);
        }
    }
}
<!--        阿里云对象存储,用于上传文件-->
<dependency>
  <groupId>com.alibaba.cloud</groupId>
  <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
  <version>2.2.0.RELEASE</version>
</dependency>
spring:
  cloud:
    alicloud:
      #短信验证码的配置
      sms:
        host: https://dfsns.market.alicloudapi.com
        path: /data/send_sms
        appcode: dbc663defb63426caefc7be0a8fe8cdf

整个配置文件里就只有appcode是自己购买短信服务后唯一的码,其他的都是固定配置

AppCode获取地址:阿里云登录 - 欢迎登录阿里云,安全稳定的云计算服务平台

2、注册用户

(1)前端以表单的形式发送请求/register给后端

<form action="/register" method="post" class="one">
<!--		<div style="color: red" th:text="${errors != null ? (#maps.containsKey(errors, 'msg') ? errors.msg : '') : ''}">-->
		<div class="register-box">
			<label class="username_label">用 户 名:
				<input name="userName" maxlength="20" type="text" >
			</label>
<!--			#maps.containsKey(errors, 'userName')表示errors这个map里包含userName的话就取出报错信息,没有的话就输出空字符串-->
			<div class="tips" style="color: red" th:text="${errors != null ? (#maps.containsKey(errors, 'userName') ? errors.userName : '') : ''}">
				错误提示区域,如果出错了就会从后端获取报错信息
			</div>
		</div>
		<div class="register-box">
			<label class="other_label">设 置 密 码:
				<input name="password" maxlength="20" type="password" >
			</label>
			<div class="tips" style="color: red" th:text="${errors != null ? (#maps.containsKey(errors, 'password') ? errors.password : '') : ''}">

			</div>
		</div>
		<div class="register-box">
			<label class="other_label">确 认 密 码:
				<input maxlength="20" type="password" >
			</label>
			<div class="tips">

			</div>
		</div>
		<div class="register-box">
			<label class="other_label">
				<span>中国 0086∨</span>
				<input name="phone" class="phone" id="phoneNum" maxlength="20" type="text" >
			</label>
			<div class="tips" style="color: red" th:text="${errors != null ? (#maps.containsKey(errors, 'phone') ? errors.phone : '') : ''}">

			</div>
		</div>
		<div class="register-box">
			<label class="other_label">验 证 码:
				<input name="code" maxlength="20" type="text"  class="caa">
			</label>
			<a id="sendCode" class=""> 发送验证码 </a>
		</div>
		<div class="arguement">
			<input type="checkbox" id="xieyi"> 阅读并同意
			<a href="/static/reg/#">《谷粒商城用户注册协议》</a>
			<a href="/static/reg/#">《隐私政策》</a>
			<div class="tips" style="color: red" th:text="${errors != null ? (#maps.containsKey(errors, 'code') ? errors.code : '') : ''}">

			</div>
			<br/>
			<div class="submit_btn">
				<button type="submit" id="submit_btn">立 即 注 册</button>
			</div>
		</div>
	</form>

(2)在认证服务的LoginController来处理请求/register

分流程:

1>获取用户输入的验证码和缓存里的验证码(这里要判断缓存中验证码为不为空)进行比较,看是否相同

2>验证码相同就立马删除验证码(令牌机制),验证码不同报错同时重定向到注册界面

3>远程调用会员服务进行真正的用户注册

4>远程调用成功就重定向到首页,失败就把错误消息封装成map同时重定向到注册界面

/**
*   用户注册
* TODO: 重定向携带数据:利用session原理,将数据放在session中。
* TODO:只要跳转到下一个页面取出这个数据以后,session里面的数据就会删掉
* TODO:分布下session问题
* RedirectAttributes:重定向也可以保留数据,不会丢失
*
* @return
*/
@PostMapping(value = "/register")
public String register(@Valid UserRegisterVo vos, BindingResult result,
                       RedirectAttributes attributes) {
    
    //1、效验验证码
    String code = vos.getCode();
    
    //获取存入Redis里的验证码(redisCode的格式是123456_1646981054661 ,123456表示验证码,1646981054661表示存入缓存的时间)
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vos.getPhone());
    //判断redis的对应的验证码是否为空
    if (!StringUtils.isEmpty(redisCode)) {
        //判断验证码是否相同
        if (code.equals(redisCode.split("_")[0])) {
            //删除验证码;令牌机制
            stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX+vos.getPhone());
            //验证码通过,真正注册,调用远程服务进行注册
            R register = memberFeignService.register(vos);
            //register.getCode() == 0表示远程调用成功
            if (register.getCode() == 0) {
                //成功
                return "redirect:http://saodaimall.com";
            } else {
                //失败
                Map<String, String> errors = new HashMap<>();
                errors.put("msg", register.getData("msg",new TypeReference<String>(){}));
                attributes.addFlashAttribute("errors",errors);
                return "redirect:http://auth.saodaimall.com/reg.html";
            }
            
            
        } else {
            //效验出错回到注册页面
            Map<String, String> errors = new HashMap<>();
            errors.put("code","验证码错误");
            attributes.addFlashAttribute("errors",errors);
            return "redirect:http://auth.saodaimall.com/reg.html";
        }
    } else {
        //redis的对应的验证码为空表示效验出错回到注册页面
        Map<String, String> errors = new HashMap<>();
        errors.put("code","验证码错误");
        attributes.addFlashAttribute("errors",errors);
        return "redirect:http://auth.saodaimall.com/reg.html";
    }
}
package com.saodai.saodaimall.auth.vo;

import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;

/**
 * 用户注册信息封装类
 **/

@Data
public class UserRegisterVo {

    @NotEmpty(message = "用户名不能为空")
    @Length(min = 6, max = 19, message="用户名长度在6-18字符")
    private String userName;

    @NotEmpty(message = "密码必须填写")
    @Length(min = 6,max = 18,message = "密码必须是6—18位字符")
    private String password;

    @NotEmpty(message = "手机号不能为空")
    @Pattern(regexp = "^[1]([3-9])[0-9]{9}$", message = "手机号格式不正确")
    private String phone;

    @NotEmpty(message = "验证码不能为空")
    private String code;

}

(3)远程调用会员服务来实现真正的注册

/**
     * 会员注册功能
     * @param vo
     * @return
     */
    @PostMapping(value = "/register")
    public R register(@RequestBody MemberUserRegisterVo vo) {

        try {
            memberService.register(vo);
        } catch (PhoneException e) {
            //BizCodeEnum.PHONE_EXIST_EXCEPTION=存在相同的手机号  15002
            return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnum.PHONE_EXIST_EXCEPTION.getMessage());
        } catch (UsernameException e) {
            //BizCodeEnum.USER_EXIST_EXCEPTION=商品库存不足  21000
            return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(),BizCodeEnum.USER_EXIST_EXCEPTION.getMessage());
        }

        return R.ok();
    }
    /**
     *    会员注册
     */

    @Override
    public void register(MemberUserRegisterVo vo) {

        MemberEntity memberEntity = new MemberEntity();

        //设置默认等级
        MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
        memberEntity.setLevelId(levelEntity.getId());

        //设置其它的默认信息
        //检查用户名和手机号是否唯一。感知异常,异常机制(异常机制就是问题就抛出具体异常,没问题就继续执行下面的语句)
        checkPhoneUnique(vo.getPhone());
        checkUserNameUnique(vo.getUserName());

        memberEntity.setNickname(vo.getUserName());
        memberEntity.setUsername(vo.getUserName());
        //密码进行MD5盐值加密(盐值加密同一个数据的每次加密结果是不一样的,通过match方法来密码校验)
        // (注意这里不能用md5直接加密放数据库,因为彩虹表可以破解md5,所谓彩虹表就是通过大量的md5数据反向退出md5
        // 注意MD5是不可逆,但是可暴力通过彩虹表破解)
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        String encode = bCryptPasswordEncoder.encode(vo.getPassword());
        memberEntity.setPassword(encode);
        memberEntity.setMobile(vo.getPhone());
        memberEntity.setGender(0);
        memberEntity.setCreateTime(new Date());

        //保存数据
        this.baseMapper.insert(memberEntity);
    }

    /**
     * 检查手机号是否重复的异常机制方法
     * @param phone
     * @throws PhoneException
     */
    @Override
    public void checkPhoneUnique(String phone) throws PhoneException {

        Long phoneCount = this.baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
        //usernameCount > 0表示手机号已经存在
        if (phoneCount > 0) {
            throw new PhoneException();
        }

    }

    /**
     * 检查用户名是否重复的异常机制方法
     * @param userName
     * @throws UsernameException
     */
    @Override
    public void checkUserNameUnique(String userName) throws UsernameException {

        Long usernameCount = this.baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("username", userName));
        //usernameCount > 0表示用户名已经存在
        if (usernameCount > 0) {
            throw new UsernameException();
        }
    }

分流程:(封装MemberEntity对象)

1>设置默认等级

package com.saodai.saodaimall.member.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;

/**
 * 会员等级
 */
@Data
@TableName("ums_member_level")
public class MemberLevelEntity implements Serializable {
	private static final long serialVersionUID = 1L;

	/**
	 * id
	 */
	@TableId
	private Long id;
	/**
	 * 等级名称
	 */
	private String name;
	/**
	 * 等级需要的成长值
	 */
	private Integer growthPoint;
	/**
	 * 是否为默认等级[0->不是;1->是]
	 */
	private Integer defaultStatus;
	/**
	 * 免运费标准
	 */
	private BigDecimal freeFreightPoint;
	/**
	 * 每次评价获取的成长值
	 */
	private Integer commentGrowthPoint;
	/**
	 * 是否有免邮特权
	 */
	private Integer priviledgeFreeFreight;
	/**
	 * 是否有会员价格特权
	 */
	private Integer priviledgeMemberPrice;
	/**
	 * 是否有生日特权
	 */
	private Integer priviledgeBirthday;
	/**
	 * 备注
	 */
	private String note;

}
<!--    查询出默认等级-->
<select id="getDefaultLevel" resultType="com.saodai.saodaimall.member.entity.MemberLevelEntity">
  SELECT * FROM ums_member_level WHERE default_status = 1
</select>
2>检查用户名和手机号是否唯一,通过异常机制来感知异常(异常机制就是问题就抛出具体异常,没问题就继续执行下面的语句)

package com.saodai.saodaimall.member.exception;


public class UsernameException extends RuntimeException {
    
    
    public UsernameException() {
        super("存在相同的用户名");
    }
}
package com.saodai.saodaimall.member.exception;


public class PhoneException extends RuntimeException {

    public PhoneException() {
        super("存在相同的手机号");
    }
}

3>密码进行MD5盐值加密

4>保存用户信息到数据库中

package com.saodai.saodaimall.member.vo;

import lombok.Data;

/**
 * 会员注册类
 **/

@Data
public class MemberUserRegisterVo {

    private String userName;

    private String password;

    private String phone;

}
package com.saodai.saodaimall.member.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
 * 会员
 */
@Data
@TableName("ums_member")
public class MemberEntity implements Serializable {
	private static final long serialVersionUID = 1L;

	/**
	 * id
	 */
	@TableId
	private Long id;
	/**
	 * 会员等级id
	 */
	private Long levelId;
	/**
	 * 用户名
	 */
	private String username;
	/**
	 * 密码
	 */
	private String password;
	/**
	 * 昵称
	 */
	private String nickname;
	/**
	 * 手机号码
	 */
	private String mobile;
	/**
	 * 邮箱
	 */
	private String email;
	/**
	 * 头像
	 */
	private String header;
	/**
	 * 性别
	 */
	private Integer gender;
	/**
	 * 生日
	 */
	private Date birth;
	/**
	 * 所在城市
	 */
	private String city;
	/**
	 * 职业
	 */
	private String job;
	/**
	 * 个性签名
	 */
	private String sign;
	/**
	 * 用户来源
	 */
	private Integer sourceType;
	/**
	 * 积分
	 */
	private Integer integration;
	/**
	 * 成长值
	 */
	private Integer growth;
	/**
	 * 启用状态
	 */
	private Integer status;
	/**
	 * 注册时间
	 */
	private Date createTime;

	/**
	 * 社交登录用户的ID
	 */
	private String socialId;

	/**
	 * 社交登录用户的名称
	 */
	private String socialName;

	/**
	 * 社交登录用户的自我介绍
	 */
	private String socialBio;

}

二、用户登录功能

(1)前端以表单的形式发送请求/login给后端

<form action="/login" method="post">
  <div style="color: red;height: 10px;text-align: center;" th:text="${errors != null ? (#maps.containsKey(errors, 'msg') ? errors.msg : '') : ''}"></div>
  <ul>
    <li class="top_1">
      <img src="/static/login/JD_img/user_03.png" class="err_img1"/>
      <input type="text" name="loginacct" placeholder=" 邮箱/用户名/已验证手机" class="user"/>
    </li>
    <li>
      <img src="/static/login/JD_img/user_06.png" class="err_img2"/>
      <input type="password" name="password" placeholder=" 密码" class="password"/>
    </li>
    <li class="bri">
      <a href="/static/login/" >忘记密码</a>
    </li>
    <li class="ent">
      <button class="btn2" type="submit">登 &nbsp; &nbsp;录</a></button>
  </li>
 </ul>
</form>

(2)在认证服务的LoginController来处理请求/login

/**
* 普通手机账号登录
* @param vo
* @param attributes
* @param session
* @return
*/
@PostMapping(value = "/login")
public String login(UserLoginVo vo, RedirectAttributes attributes, HttpSession session) {
    
    //远程登录
    R login = memberFeignService.login(vo);
    
    //login.getCode() == 0表示远程调用成功
    if (login.getCode() == 0) {
        MemberResponseVo data = login.getData("data", new TypeReference<MemberResponseVo>() {});
        session.setAttribute(LOGIN_USER,data);
        return "redirect:http://saodaimall.com";
    } else {
        Map<String,String> errors = new HashMap<>();
        errors.put("msg",login.getData("msg",new TypeReference<String>(){}));
        attributes.addFlashAttribute("errors",errors);
        return "redirect:http://auth.saodaimall.com/login.html";
    }
    }
package com.saodai.saodaimall.auth.vo;

import lombok.Data;

/**
 * 用户登录信息封装类
 **/

@Data
public class UserLoginVo {
    //账户
    private String loginacct;
    //密码
    private String password;
}
package com.saodai.common.vo;

import lombok.Data;
import lombok.ToString;

import java.io.Serializable;
import java.util.Date;

/**
 *会员信息
 **/

@ToString
@Data
public class MemberResponseVo implements Serializable {

    private static final long serialVersionUID = 5573669251256409786L;

    private Long id;
    /**
     * 会员等级id
     */
    private Long levelId;
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 昵称
     */
    private String nickname;
    /**
     * 手机号码
     */
    private String mobile;
    /**
     * 邮箱
     */
    private String email;
    /**
     * 头像
     */
    private String header;
    /**
     * 性别
     */
    private Integer gender;
    /**
     * 生日
     */
    private Date birth;
    /**
     * 所在城市
     */
    private String city;
    /**
     * 职业
     */
    private String job;
    /**
     * 个性签名
     */
    private String sign;
    /**
     * 用户来源
     */
    private Integer sourceType;
    /**
     * 积分
     */
    private Integer integration;
    /**
     * 成长值
     */
    private Integer growth;
    /**
     * 启用状态
     */
    private Integer status;
    /**
     * 注册时间
     */
    private Date createTime;
    /**
     * 社交登录用户的ID
     */
    private String socialId;
    /**
     * 社交登录用户的名称
     */
    private String socialName;

    /**
     * 社交登录用户的自我介绍
     */
    private String socialBio;


}

(3)远程调用会员服务来实现真正的登录

 /**
     * 会员登录功能
     * @param vo
     * @return
     */
   @PostMapping(value = "/login")
    public R login(@RequestBody MemberUserLoginVo vo) {
       //MemberUserLoginVo就是上面的UserLoginVo只是类名不一样
        MemberEntity memberEntity = memberService.login(vo);

        if (memberEntity != null) {
            return R.ok().setData(memberEntity);
        } else {
            return R.error(BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getMessage());
        }
    }
    /**
     * 会员登录功能
     * @param vo
     * @return
     */
    @Override
    public MemberEntity login(MemberUserLoginVo vo) {
         //获取用户登录账号
        String loginacct = vo.getLoginacct();
        String password = vo.getPassword();

        //1、去数据库查询 SELECT * FROM ums_member WHERE username = ? OR mobile = ?
        //通过用户名或手机号登录都是可以的
        MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>()
                .eq("username", loginacct).or().eq("mobile", loginacct));

        if (memberEntity == null) {
            //登录失败
            return null;
        } else {
            //获取到数据库里的password
            String password1 = memberEntity.getPassword();
            BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            //进行密码匹配
            boolean matches = passwordEncoder.matches(password, password1);
            if (matches) {
                //登录成功
                return memberEntity;
            }
        }

        return null;
    }
package com.saodai.saodaimall.member.vo;

import lombok.Data;

/**
 * 会员登录
 **/

@Data
public class MemberUserLoginVo {

    private String loginacct;

    private String password;

}

社交登录(OAuth2.0)

这里由于微博开发者权限申请太慢了就使用gitee来实现社交登录

(1)前端调用第三方应用作为社交登录

这个跟以往的模式不一样,以往是前段直接给后端发送请求,然后后端处理请求,这个是先调用第三方应用作为社交登录(也就是先跳转到gitee的登录授权页面),然后用户登录自己的gitee账号密码进行授权,授权成功后会跳转到指定的应用回调地址,然后在后端来处理这个应用回调地址的请求

gitee开发者后台管理链接:https://gitee.com/oauth/applications/16285

调用gitee第三方登录的url地址:<a href="https://gitee.com/oauth/authorize?client_id=自己应用的Client ID&redirect_uri=自己应用的成功回调地址&response_type=code&state=1">

<li>
  <a href="https://gitee.com/oauth/authorize?client_id=32459f971ce6d89cfb9f70899525455d0653cb804f16b38a304e3447dc97d673&redirect_uri=http://auth.saodaimall.com/callback&response_type=code&state=1">
    <img style="width: 50px;height: 18px;margin-top: 35px;" src="/static/login/JD_img/gitee.png"/>
  </a>
</li>

(2)社交服务OAuth2Controller来处理应用回调地址/callback请求

分流程:(其实就只有三行代码是要自己配置的(OAuth2Controller的gitee的42-44行),其他的基本上是固定的)

1>封装AccessTokenDTO对象然后发给码云服务器,如果AccessTokenDTO对象正确的话就返回一个通行令牌(其实准确来说是用户授权后会返回一个code,然后通过code来去找码云服务器获取到一个通行令牌,最后通过这个通行令牌去找码云服务器要这个用户在gitee上公开的资料信息)

2>获取到了access_token通行令牌,转为通用gitee社交登录GiteeUser对象

3>远程调用会员服务来进行社交登录

package com.saodai.saodaimall.auth.controller;

import com.alibaba.fastjson.TypeReference;
import com.saodai.common.utils.R;
import com.saodai.common.vo.MemberResponseVo;
import com.saodai.saodaimall.auth.component.GitheeProvider;
import com.saodai.saodaimall.auth.feign.MemberFeignService;
import com.saodai.saodaimall.auth.vo.AccessTokenDTO;
import com.saodai.saodaimall.auth.vo.GiteeUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpSession;

import static com.saodai.common.constant.AuthServerConstant.LOGIN_USER;


/**
* 社交第三方授权登录
**/

@Slf4j
@Controller
public class OAuth2Controller {
    
    @Autowired
    private MemberFeignService memberFeignService;
    @Autowired
    private AccessTokenDTO accessTokenDTO;
    @Autowired
    private GitheeProvider githeeProvider;
    
    @GetMapping(value = "/callback")
    // /callback?code=e867a1f4575d4a6161e3249423a0403898253bc593e4b031a8771739ee6769f5&state=1
    public String gitee(@RequestParam(name = "code") String code,@RequestParam(name = "state") String state, HttpSession session) throws Exception {
        System.out.println(code);
        //下面三行代码都是自己应用的值,可以在gitee的第三方应用中看到对应的值
        accessTokenDTO.setClient_id("32459f971ce6d89cfb9f70899525455d0653cb804f16b38a304e3447dc97d673");
        accessTokenDTO.setClient_secret("f3046c911c03cadcded986062708150d4232af3ca6aef0259e5a0198d2c15ba5");
        accessTokenDTO.setRedirect_uri("http://auth.saodaimall.com/callback");
        accessTokenDTO.setCode(code);
        accessTokenDTO.setState(state);
        String accessToken = githeeProvider.getAccessToken(accessTokenDTO);
        //2、处理
        if (!StringUtils.isEmpty(accessToken)) {
            //获取到了access_token,转为通用gitee社交登录对象
            GiteeUser giteeUser = githeeProvider.getGiteeUser(accessToken);
            //知道了哪个社交用户
            //1)、当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息,以后这个社交账号就对应指定的会员)
            //登录或者注册这个社交用户
            //调用远程服务
            R oauthLogin = memberFeignService.oauthLogin(giteeUser);
            if (oauthLogin.getCode() == 0) {
                MemberResponseVo memberResponseVo = oauthLogin.getData("data", new TypeReference<MemberResponseVo>() {});
                log.info("登录成功:用户信息:{}",memberResponseVo.toString());
                
                //1、第一次使用session,命令浏览器保存卡号,JSESSIONID这个cookie
                //以后浏览器访问哪个网站就会带上这个网站的cookie
                //TODO 1、默认发的令牌。当前域(解决子域session共享问题)
                //TODO 2、使用JSON的序列化方式来序列化对象到Redis中
                session.setAttribute(LOGIN_USER,memberResponseVo);
                
                //2、登录成功跳回首页
                return "redirect:http://saodaimall.com";
            } else {
                
                return "redirect:http://auth.saodaimall.com/login.html";
            }
            
        } else {
            return "redirect:http://auth.saodaimall.com/login.html";
        }
        
    }
    
}
package com.saodai.saodaimall.auth.vo;


/**
 * AccessTokenDTO对象封装(gitee社交登录令牌)
 */

import org.springframework.stereotype.Component;

@Component
public class AccessTokenDTO {
    private String client_id;
    private String client_secret;
    private String code;
    private String redirect_uri;
    private String state;

    public String getClient_id() {
        return client_id;
    }

    public void setClient_id(String client_id) {
        this.client_id = client_id;
    }

    public String getClient_secret() {
        return client_secret;
    }

    public void setClient_secret(String client_secret) {
        this.client_secret = client_secret;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getRedirect_uri() {
        return redirect_uri;
    }

    public void setRedirect_uri(String redirect_uri) {
        this.redirect_uri = redirect_uri;
    }

    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }
}
package com.saodai.saodaimall.auth.component;


import com.alibaba.fastjson.JSON;
import com.saodai.saodaimall.auth.vo.AccessTokenDTO;
import com.saodai.saodaimall.auth.vo.GiteeUser;
import okhttp3.*;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 请求码云服务器
 */
@Component
public class GitheeProvider {

    //发起post请求获取AccessToken
    public String getAccessToken(AccessTokenDTO accessTokenDTO){
        MediaType mediaType= MediaType.get("application/json; charset=utf-8");
        OkHttpClient client = new OkHttpClient();
        RequestBody body = RequestBody.create(mediaType, JSON.toJSONString(accessTokenDTO));
        Request request = new Request.Builder()
                .url("https://gitee.com/oauth/token?grant_type=authorization_code&code="+accessTokenDTO.getCode()+
                        "&client_id="+accessTokenDTO.getClient_id()+"&redirect_uri="+accessTokenDTO.getRedirect_uri()+
                        "&client_secret="+accessTokenDTO.getClient_secret())
                .post(body)
                .build();
        try (Response response = client.newCall(request).execute()) {
            String string = response.body().string();
            System.out.println(string);
            String str1 = string.split(":")[1];
            String str2 = str1.split("\"")[1];
            return str2;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    //发起get请求返回GitUser对象,
    public GiteeUser getGiteeUser(String token){
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
                .url("https://gitee.com/api/v5/user?access_token="+token)
                .build();
        try (Response response = client.newCall(request).execute()) {
            String string=response.body().string();
            GiteeUser giteeUser = JSON.parseObject(string, GiteeUser.class);
            return giteeUser;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}
package com.saodai.saodaimall.auth.vo;

import lombok.Data;

/**
 * GiteeUser对象封装(社交登录的gitee对象)
 */
@Data
public class GiteeUser {
    //gitee用户名称
    private String name;
    //gitee用户id
    private String id;
    //gitee用户自我介绍
    private String bio;


}

(3)远程调用会员服务来进行社交登录

  /**
     * 社交登录
     * @param giteeUser
     * @return
     * @throws Exception
     */
  @PostMapping(value = "/oauth2/login")
    public R oauthLogin(@RequestBody GiteeUser giteeUser) throws Exception {

        MemberEntity memberEntity = memberService.login(giteeUser);

        if (memberEntity != null) {
            return R.ok().setData(memberEntity);
        } else {
            return R.error(BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getMessage());
        }
    }
    /**
     * 社交登录
     * @param giteeUser
     * @return
     * @throws Exception
     */
    @Override
    public MemberEntity login(GiteeUser giteeUser) throws Exception {

        //获取gitee用户唯一id
        String giteeUserId = giteeUser.getId();

        //1、判断当前社交用户是否已经登录过系统
        MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("social_id", giteeUserId));
        //这个用户已经注册过
        if (memberEntity != null) {
            return memberEntity;
        } else {
            //2、没有查到当前社交用户对应的记录我们就需要注册一个
            MemberEntity register = new MemberEntity();
            //社交gitee登录的id作为会员id
            register.setId(Long.valueOf(giteeUserId));
            register.setSocialName(giteeUser.getName());
            register.setUsername(giteeUser.getName());
            register.setNickname(giteeUser.getName());
            register.setCreateTime(new Date());
            register.setSocialBio(giteeUser.getBio());
            register.setSocialId(giteeUserId);
            //把用户信息插入到数据库中
            this.baseMapper.insert(register);
            return register;

        }

    }
package com.saodai.saodaimall.member.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
 * 会员
 */
@Data
@TableName("ums_member")
public class MemberEntity implements Serializable {
	private static final long serialVersionUID = 1L;

	/**
	 * id
	 */
	@TableId
	private Long id;
	/**
	 * 会员等级id
	 */
	private Long levelId;
	/**
	 * 用户名
	 */
	private String username;
	/**
	 * 密码
	 */
	private String password;
	/**
	 * 昵称
	 */
	private String nickname;
	/**
	 * 手机号码
	 */
	private String mobile;
	/**
	 * 邮箱
	 */
	private String email;
	/**
	 * 头像
	 */
	private String header;
	/**
	 * 性别
	 */
	private Integer gender;
	/**
	 * 生日
	 */
	private Date birth;
	/**
	 * 所在城市
	 */
	private String city;
	/**
	 * 职业
	 */
	private String job;
	/**
	 * 个性签名
	 */
	private String sign;
	/**
	 * 用户来源
	 */
	private Integer sourceType;
	/**
	 * 积分
	 */
	private Integer integration;
	/**
	 * 成长值
	 */
	private Integer growth;
	/**
	 * 启用状态
	 */
	private Integer status;
	/**
	 * 注册时间
	 */
	private Date createTime;

	/**
	 * 社交登录用户的ID
	 */
	private String socialId;

	/**
	 * 社交登录用户的名称
	 */
	private String socialName;

	/**
	 * 社交登录用户的自我介绍
	 */
	private String socialBio;

}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

好运仔dzl

打赏有用的话还要工作干嘛

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值