其实仅实现web单点登录并没有太多的开发工作量,当然如果要实现一整套符合oauth2.0标准的认证系统,并且加上对接外部认证源,应用注册管理,系统日志管理,资源管理,安全管理等就不是那么简单的一项任务了。大家可以搜一下开源项目参考学习。以下我们前端用javascript,后端用nodejs+express来手写一个web sso。
先介绍下技术方案:
蓝色部分是app侧express的拦截器里要实现的功能;红色部分是sso侧要提供的服务功能:包括登录页面、ticket生成、ticket查询等。ticket以query参数的方式附加在url里传递。
将sso的session存放在redis中,这样sso服务器还可以扩展到多节点;将客户端登录sso的sid(会话ID和IP经过加密算法生成),存放到客户端cookie,利于后续服务器取得比对防伪;还将ticket也存放在redis,生成后一次查询后立即删除。
该sso方案包含了密码加密传输和登录失败策略。
以下是代码实现。
//app侧后端
function ssocheck(redurl,queryticketurl,ssourl,logger,postProcess,req,res,next){
if (!req.session.user) {
//logger.info("未登录该应用");
let ticketid=req.query.ticket;
if (ticketid!=undefined){
//logger.info("有票据");
axios.get(queryticketurl+ticketid).then(resax => {
//logger.info("查询反馈数据: "+JSON.stringify(resax.data));
if (resax.data.msg!='not found') {
postProcess(resax.data.ssouser,req,res,next);//取得sso用户后的app处理,比如查询app用户等操作
}
else {
//logger.error("无效ticket");
let urlParsed = new URL(redurl);
urlParsed.searchParams.delete("ticket");
let newredurl=urlParsed.toString()
res.redirect(ssourl+newredurl);
}
})
.catch(errorax => {
//logger.error("查询ticket出错");
res.json({msg:"查询ticket出错"}); });
}
else {
//logger.info("无票据 重定向到sso")
res.redirect(ssourl+redurl);
}
}
else { next(); }
}
app.use((req,res,next)=>{
if (req.url=="/") { ... }
else if (...) { next(); }
else { let redurl=app_prefix+ req.originalUrl; ssocheck(redurl,queryticketurl,ssourl,logger,postProcess,req,res,next); }
});
//sso前端 为避免口令明文在网络上传递,采用加密方式,当然首先是必须使用https的
function login(){
if ((document.getElementById('uid').value.trim()=="")||(document.getElementById('upw').value.trim()=="")) { alert("请输入用户名和密码!"); }
else {
$.get("/sso/reqlogin",(res)=>{
let fupw=document.getElementById('upw').value;
let ekey=CryptoJS.enc.Hex.parse(res.key);
let eiv=CryptoJS.enc.Hex.parse(res.iv);
let enpw=CryptoJS.AES.encrypt(fupw, ekey, {
iv: eiv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
let enpwstr=enpw.ciphertext.toString();
$.post("/sso/login",{"reqid":res.reqid,"uid": nuid,"upw": enpwstr},(obj)=>{
if (obj.msg=="pass") {
var reg = new RegExp("(^|&)" + "redurl" + "=([^&]*)(&|$)","i");
var r = location.search.substr(1).match(reg);
if (r!=null) { document.location =r[2]+'?ticket='+obj.ticket; }
else { document.location="/sso/apps.html"; }//sso本身也可以直接访问,放几个应用图标好直接点入
}
else { alert(obj.msg); }
})
})
}
}
//sso后端
function getsidfromcookie(cookiestr){
let cookie = {}
let cookies = cookiestr ? cookiestr.split(';') : []
if (cookies.length > 0) {
cookies.forEach(item => {
if (item) {
let cookieArray = item.split('=')
if (cookieArray && cookieArray.length > 0) {
let key = cookieArray[0].trim()
let value = cookieArray[1] ? cookieArray[1].trim() : undefined
cookie[key] = value
}
}
})
}
return cookie["sid"]
}
app.use((req,res,next)=>{
if (req.url=="/") { res.redirect('/sso/apps.html'); }
else if (...) { next(); }
else {
let getsid=getsidfromcookie(req.headers.cookie); //客户端cookie中的sid与redis中保存的sid比对,一致则通过,否则重新登录
if (getsid==undefined) {
if (req.query.redurl!=undefined) { res.redirect('/sso/login.html?redurl='+req.query.redurl);} else { res.redirect('/sso/login.html'); }
}
else {
let clientip = req.header('x-forwarded-for')?req.header('x-forwarded-for'):req.ip;
let csid=myencrypt(clientip+req.sessionID);
if ((getsid!=csid)||(!req.session.user)) {
if (req.query.redurl!=undefined) { res.redirect('/sso/login.html?redurl='+req.query.redurl);} else { res.redirect('/sso/login.html'); }
}
else { next(); }
}
}
});
app.post('/login',urlencodedParser,(req,res)=>{
let obj= req.body;
let clientip=req.header('x-forwarded-for')?req.header('x-forwarded-for'):req.ip;
logger.info(obj.uid+" 尝试登录");
let now=new Date();
let item=loginfaillist.find(itm=>(itm.ip==clientip));
if ((item!=undefined)&&(now<item.permittime)) { res.json({msg:"该IP已触发登录异常,请于"+item.permittime.toLocaleTimeString()+"后再尝试"}); }
else {
client.get(obj.reqid+clientip).then(val=>{
if (val==null) { logger.error("认证出错 "+obj.uid); req.session.destroy(); res.json({msg:"认证出错"}); addfail(now,clientip); }
else {
let lk=val.substr(0,32); let iv=val.substr(32);
client.del(obj.reqid+clientip);
let encryptedHexStr = CryptoJS.enc.Hex.parse(obj.upw); let srcs = CryptoJS.enc.Base64.stringify(encryptedHexStr);
let depw=CryptoJS.AES.decrypt(srcs, CryptoJS.enc.Hex.parse(lk), {
iv: CryptoJS.enc.Hex.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
let depwstr=CryptoJS.enc.Utf8.stringify(depw);
callauthenticate(obj.uid, depwstr, (err)=> {//替换成相应的认证调用
if (err) { logger.error("认证出错 "+obj.uid); req.session.destroy(); res.json({msg:"认证出错"}); addfail(now,clientip); }
else {
logger.info("认证成功 "+obj.uid);
loginfaillist=loginfaillist.filter(itm=>(itm.ip!=req.ip));
callfindUser(obj.uid, (err, user)=> { //替换成相应的查询用户属性调用
req.session.user={ userid:obj.uid.toLowerCase(),username:user.sn };
let clientip=req.header('x-forwarded-for')?req.header('x-forwarded-for'):req.ip;
let sid=myencrypt(clientip+req.sessionID);
res.setHeader('Set-Cookie', 'sid='+sid+';')
let ticketid="t_"+moment().format("YYYYMMDDHHmmssSSS")+uuidv4().replace(/-/g, '');
client.set(ticketid,JSON.stringify(req.session.user));
res.json({msg:"pass",ticket:ticketid});
})
}
});
}
});
}
});
app.get('/queryticket',(req,res)=>{
client.get(req.query.ticket).then(val=> {
if (val!=null) {
let jssouser=JSON.parse(val);
client.del(req.query.ticket);
res.json({msg:'found',ssouser:jssouser.userid});
}
else { res.json({msg:'not found'}); }
})
});
app.get('/reqlogin',(req,res)=>{
let clientip=req.header('x-forwarded-for')?req.header('x-forwarded-for'):req.ip;
let reqid="l_"+moment().format("YYYYMMDDHHmmssSSS")+uuidv4().replace(/-/g, '');
let loginkey=uuidv4().replace(/-/g, '');
let iv=uuidv4().replace(/-/g, '');
client.set(reqid+clientip,loginkey+iv).then(val=> {res.json({"reqid":reqid,"key":loginkey,"iv":iv}); });
client.expire(reqid,5);
});
app.get('/logout',(req,res)=>{
let redurl=(req.query.redurl!=undefined)?req.query.redurl:"/sso/index.html";
logger.info("logout 页面重定向到 "+redurl);
req.session.destroy( () => { res.redirect(redurl); } );
});
app.get('/sso',(req,res)=>{
let ticketid=moment().format("YYYYMMDDHHmmssSSS")+uuidv4().replace(/-/g, '');
client.set(ticketid,JSON.stringify(req.session.user));
let urlParsed = new URL(req.query.redurl);
urlParsed.searchParams.delete("ticket");
urlParsed.searchParams.append("ticket",ticketid);
let redurl=urlParsed.toString()
logger.info("页面重定向到 "+redurl);
res.redirect(redurl);
});