diff --git a/apps/static/css/otp.css b/apps/static/css/otp.css new file mode 100644 index 000000000..4c9ed2606 --- /dev/null +++ b/apps/static/css/otp.css @@ -0,0 +1,146 @@ +/*公共样式*/ +*{ + margin:0; + padding: 0; + outline: none; +} +a{ + text-decoration: none; + color:black +} +li{ + list-style:none; +} +button{ + outline: none; +} +.red-fonts{ + color: #ed5565; + font-size: 15px; + text-align: center; +} + +/*header样式*/ +header{ + overflow:hidden ; + background: #dedede; + padding:15px 200px; +} +header .logo a{ + float:left; +} +header .logo a:nth-child(2){ + padding-top: 13px; +} +header div:nth-child(1){ + float:left; +} +header div:nth-child(2){ + float:right; + font-size: 12px; + padding-top: 20px; +} +header div:nth-child(2) a:hover{ + color:#1ab394; +} + +/*article样式*/ +article{ + padding-top: 50px; + padding:50px 370px +} +article ul{ + float: left; + position: relative; + left: 50%; + margin-bottom: 50px; +} +article ul li{ + float: left; + position: relative; + right: 50%; +} +article ul li span,article ul li i{ + display: block; + float: left; +} +article ul li span{ + width: 150px; + height: 4px; + margin: 15px 0; + background: black; +} +article ul li:last-child{ + padding-left: 2px; +} +.iconfont{ + font-size: 30px; + color: grey; +} +.back{ + margin-left:-15px; +} +.active{ + color:#1ab394; +} +.clearfix:after { + content:""; + height:0; + visibility:hidden; + display:block; + clear:both; +} +.verify{ + text-align: center; + font-size: 14px; + /*padding-left:70px;*/ + color: grey; +} +.verify span{ + color:red; +} +.line{ + width: 500px; + height:1px; + margin-left:100px; + margin-top:10px ; + background: grey; +} + +/*输入框样式*/ +.form-input{ + text-align: center; + margin: 20px auto; +} +.form-input input{ + width: 200px; + height: 30px; + padding-left: 10px; + outline: none; +} +/*身份验证*/ +/*安装应用*/ +.verify div{ + display: inline-block; +} +.verify div:nth-child(3){ + margin-left: 58px; +} +.next{ + margin: 20px auto; + display: block; + width: 214px; + line-height: 34px; + background: #1ab394; + text-align: center; + border-radius: 6px; + color: white; +} +/*绑定TOTP*/ + +/*版权信息*/ +footer{ + text-align:center; + font-size: 14px; + color: #1a1a1a; +} \ No newline at end of file diff --git a/apps/static/fonts/font_otp/iconfont.css b/apps/static/fonts/font_otp/iconfont.css new file mode 100644 index 000000000..bcc9331ef --- /dev/null +++ b/apps/static/fonts/font_otp/iconfont.css @@ -0,0 +1,25 @@ + +@font-face {font-family: "iconfont"; + src: url('iconfont.eot?t=1523776860888'); /* IE9*/ + src: url('iconfont.eot?t=1523776860888#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('data:application/x-font-woff;charset=utf-8;base64,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') format('woff'), + url('iconfont.ttf?t=1523776860888') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/ + url('iconfont.svg?t=1523776860888#iconfont') format('svg'); /* iOS 4.1- */ +} + +.iconfont { + font-family:"iconfont" !important; + font-size:16px; + font-style:normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-duigou:before { content: "\e632"; } + +.icon-step:before { content: "\e60e"; } + +.icon-step1:before { content: "\e60f"; } + +.icon-step2:before { content: "\e610"; } + diff --git a/apps/static/fonts/font_otp/iconfont.eot b/apps/static/fonts/font_otp/iconfont.eot new file mode 100644 index 000000000..41bc9e0c6 Binary files /dev/null and b/apps/static/fonts/font_otp/iconfont.eot differ diff --git a/apps/static/fonts/font_otp/iconfont.js b/apps/static/fonts/font_otp/iconfont.js new file mode 100644 index 000000000..22342ce9e --- /dev/null +++ b/apps/static/fonts/font_otp/iconfont.js @@ -0,0 +1 @@ +(function(window){var svgSprite='';var script=function(){var scripts=document.getElementsByTagName("script");return scripts[scripts.length-1]}();var shouldInjectCss=script.getAttribute("data-injectcss");var ready=function(fn){if(document.addEventListener){if(~["complete","loaded","interactive"].indexOf(document.readyState)){setTimeout(fn,0)}else{var loadFn=function(){document.removeEventListener("DOMContentLoaded",loadFn,false);fn()};document.addEventListener("DOMContentLoaded",loadFn,false)}}else if(document.attachEvent){IEContentLoaded(window,fn)}function IEContentLoaded(w,fn){var d=w.document,done=false,init=function(){if(!done){done=true;fn()}};var polling=function(){try{d.documentElement.doScroll("left")}catch(e){setTimeout(polling,50);return}init()};polling();d.onreadystatechange=function(){if(d.readyState=="complete"){d.onreadystatechange=null;init()}}}};var before=function(el,target){target.parentNode.insertBefore(el,target)};var prepend=function(el,target){if(target.firstChild){before(el,target.firstChild)}else{target.appendChild(el)}};function appendSvg(){var div,svg;div=document.createElement("div");div.innerHTML=svgSprite;svgSprite=null;svg=div.getElementsByTagName("svg")[0];if(svg){svg.setAttribute("aria-hidden","true");svg.style.position="absolute";svg.style.width=0;svg.style.height=0;svg.style.overflow="hidden";prepend(svg,document.body)}}if(shouldInjectCss&&!window.__iconfont__svg__cssinject__){window.__iconfont__svg__cssinject__=true;try{document.write("")}catch(e){console&&console.log(e)}}ready(appendSvg)})(window) \ No newline at end of file diff --git a/apps/static/fonts/font_otp/iconfont.svg b/apps/static/fonts/font_otp/iconfont.svg new file mode 100644 index 000000000..66b68a6a3 --- /dev/null +++ b/apps/static/fonts/font_otp/iconfont.svg @@ -0,0 +1,45 @@ + + + + + +Created by iconfont + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/static/fonts/font_otp/iconfont.ttf b/apps/static/fonts/font_otp/iconfont.ttf new file mode 100644 index 000000000..5be075c12 Binary files /dev/null and b/apps/static/fonts/font_otp/iconfont.ttf differ diff --git a/apps/static/fonts/font_otp/iconfont.woff b/apps/static/fonts/font_otp/iconfont.woff new file mode 100644 index 000000000..57ca66199 Binary files /dev/null and b/apps/static/fonts/font_otp/iconfont.woff differ diff --git a/apps/static/img/authenticator_android.png b/apps/static/img/authenticator_android.png new file mode 100644 index 000000000..cb357525d Binary files /dev/null and b/apps/static/img/authenticator_android.png differ diff --git a/apps/static/img/authenticator_iphone.png b/apps/static/img/authenticator_iphone.png new file mode 100644 index 000000000..fd5b4e8eb Binary files /dev/null and b/apps/static/img/authenticator_iphone.png differ diff --git a/apps/static/img/otp_auth.png b/apps/static/img/otp_auth.png new file mode 100644 index 000000000..63964a098 Binary files /dev/null and b/apps/static/img/otp_auth.png differ diff --git a/apps/static/js/plugins/qrcode/qrcode.min.js b/apps/static/js/plugins/qrcode/qrcode.min.js new file mode 100755 index 000000000..993e88f39 --- /dev/null +++ b/apps/static/js/plugins/qrcode/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/apps/users/api.py b/apps/users/api.py index 5cedd0c5f..c64c52b44 100644 --- a/apps/users/api.py +++ b/apps/users/api.py @@ -16,7 +16,7 @@ from .tasks import write_login_log_async from .models import User, UserGroup from .permissions import IsSuperUser, IsValidUser, IsCurrentUserOrReadOnly, \ IsSuperUserOrAppUser -from .utils import check_user_valid, generate_token +from .utils import check_user_valid, generate_token, get_login_ip, check_otp_code from common.mixins import IDInFilterMixin from common.utils import get_logger @@ -129,47 +129,75 @@ class UserToken(APIView): class UserProfile(APIView): permission_classes = (IsValidUser,) + serializer_class = UserSerializer def get(self, request): - return Response(request.user.to_json()) + # return Response(request.user.to_json()) + return Response(self.serializer_class(request.user).data) def post(self, request): - return Response(request.user.to_json()) + return Response(self.serializer_class(request.user).data) class UserAuthApi(APIView): permission_classes = (AllowAny,) + serializer_class = UserSerializer def post(self, request): + otp_check = request.data.get('otp_check', None) + + if otp_check: + # otp验证 + return self.check_auth_otp(request) + else: + # password验证 + return self.check_auth_password(request) + + def check_auth_password(self, request): + user, msg = self.check_user_valid(request) + + if user: + token = generate_token(request, user) + if not user.otp_enabled: + self.write_login_log(request, user) + return Response({'token': token, 'user': self.serializer_class(user).data}) + else: + return Response({'msg': msg}, status=401) + + def check_auth_otp(self, request): + otp_code = request.data.get('otp_code', '') + user, msg = self.check_user_valid(request) + if user: + token = generate_token(request, user) + if check_otp_code(user.otp_secret_key, otp_code): + self.write_login_log(request, user) + return Response({'token': token, 'user': self.serializer_class(user).data}) + return Response({'msg': msg}, status=401) + + @staticmethod + def check_user_valid(request): username = request.data.get('username', '') password = request.data.get('password', '') public_key = request.data.get('public_key', '') - login_type = request.data.get('login_type', '') - login_ip = request.data.get('remote_addr', None) - user_agent = request.data.get('HTTP_USER_AGENT', '') - - if not login_ip: - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') - - if x_forwarded_for and x_forwarded_for[0]: - login_ip = x_forwarded_for[0] - else: - login_ip = request.META.get("REMOTE_ADDR") - user, msg = check_user_valid( username=username, password=password, public_key=public_key ) + return user, msg - if user: - token = generate_token(request, user) - write_login_log_async.delay( - user.username, ip=login_ip, - type=login_type, user_agent=user_agent, - ) - return Response({'token': token, 'user': user.to_json()}) - else: - return Response({'msg': msg}, status=401) + @staticmethod + def write_login_log(request, user): + login_ip = request.data.get('remote_addr', None) + login_type = request.data.get('login_type', '') + user_agent = request.data.get('HTTP_USER_AGENT', '') + + if not login_ip: + login_ip = get_login_ip(request) + + write_login_log_async.delay( + user.username, ip=login_ip, + type=login_type, user_agent=user_agent, + ) class UserConnectionTokenApi(APIView): diff --git a/apps/users/forms.py b/apps/users/forms.py index cbb9beaf5..03b1e21cb 100644 --- a/apps/users/forms.py +++ b/apps/users/forms.py @@ -18,6 +18,18 @@ class UserLoginForm(AuthenticationForm): captcha = CaptchaField() +class UserCheckPasswordForm(forms.Form): + username = forms.CharField(label=_('Username'), max_length=100) + password = forms.CharField( + label=_('Password'), widget=forms.PasswordInput, + max_length=128, strip=False + ) + + +class UserCheckOtpCodeForm(forms.Form): + otp_code = forms.CharField(label=_('Otp_code'), max_length=6) + + class UserCreateUpdateForm(forms.ModelForm): role_choices = ((i, n) for i, n in User.ROLE_CHOICES if i != User.ROLE_APP) password = forms.CharField( diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 06761a5df..365f3428c 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -45,7 +45,7 @@ class User(AbstractUser): wechat = models.CharField(max_length=128, blank=True, verbose_name=_('Wechat')) phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_('Phone')) otp_level = models.SmallIntegerField(default=0, choices=OTP_LEVEL_CHOICES, verbose_name=_('Enable OTP')) - otp_secret_key = models.CharField(max_length=16, blank=True) + otp_secret_key = models.CharField(max_length=16, blank=True, null=True) # Todo: Auto generate key, let user download _private_key = models.CharField(max_length=5000, blank=True, verbose_name=_('Private key')) _public_key = models.CharField(max_length=5000, blank=True, verbose_name=_('Public key')) @@ -211,15 +211,20 @@ class User(AbstractUser): def otp_enabled(self): return self.otp_level > 0 - def enabled_otp(self): - self.otp_level = 1 + @property + def otp_force_enabled(self): + return self.otp_level == 2 + + def enable_otp(self): + if not self.otp_force_enabled: + self.otp_level = 1 def force_enable_otp(self): self.otp_level = 2 - @property - def otp_force_enabled(self): - return self.otp_level == 2 + def disable_otp(self): + self.otp_level = 0 + self.otp_secret_key = '' def to_json(self): return OrderedDict({ @@ -233,6 +238,7 @@ class User(AbstractUser): 'groups': [group.name for group in self.groups.all()], 'wechat': self.wechat, 'phone': self.phone, + 'otp_level': self.otp_level, 'comment': self.comment, 'date_expired': self.date_expired.strftime('%Y-%m-%d %H:%M:%S') if self.date_expired is not None else None }) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 9d14a1d3a..450c63823 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -19,7 +19,10 @@ class UserSerializer(BulkSerializerMixin, serializers.ModelSerializer): class Meta: model = User list_serializer_class = BulkListSerializer - exclude = ['first_name', 'last_name', 'password', '_private_key', '_public_key'] + exclude = [ + 'first_name', 'last_name', 'password', '_private_key', + '_public_key', 'otp_secret_key', 'user_permissions' + ] def get_field_names(self, declared_fields, info): fields = super(UserSerializer, self).get_field_names(declared_fields, info) diff --git a/apps/users/templates/users/_base_otp.html b/apps/users/templates/users/_base_otp.html new file mode 100644 index 000000000..fb32fb425 --- /dev/null +++ b/apps/users/templates/users/_base_otp.html @@ -0,0 +1,87 @@ +{% load static %} +{% load i18n %} + + + + + + Jumpserver + + + + + + + + + +
+ +
+ 首页 + + 帮助中心 + + GitHub +
+
+ + +
+
+
    +
  • +
    + + +
    +
    验证身份
    +
  • +
  • +
    + + +
    +
    安装应用
    +
  • +
  • +
    + + +
    +
    绑定TOTP
    +
  • +
  • +
    + +
    +
    完成
    +
  • +
+
+
+
安全令牌验证  账户 {{ user.username }}  请按照以下步骤完成绑定操作
+
+ + {% block content %} + {% endblock %} +
+
+ + +
+ +
+ {% include '_copyright.html' %} +
+ +
+ + + + diff --git a/apps/users/templates/users/login_otp.html b/apps/users/templates/users/login_otp.html new file mode 100644 index 000000000..e8bfc75e8 --- /dev/null +++ b/apps/users/templates/users/login_otp.html @@ -0,0 +1,87 @@ +{% load static %} +{% load i18n %} + + + + + + + Jumpserver + + {% include '_head_css_js.html' %} + + + + + + + +
+
+
+

欢迎使用Jumpserver开源堡垒机

+

+ 全球首款完全开源的堡垒机,使用GNU GPL v2.0开源协议,是符合 4A 的专业运维审计系统。 +

+

+ 使用Python / Django 进行开发,遵循 Web 2.0 规范,配备了业界领先的 Web Terminal 解决方案,交互界面美观、用户体验好。 +

+

+ 采纳分布式架构,支持多机房跨区域部署,中心节点提供 API,各机房部署登录节点,可横向扩展、无并发访问限制。 +

+

+ 改变世界,从一点点开始。 +

+ +
+
+
+
+ + {% trans '二次认证' %} +
+
+ +
+

账号保护已开启,请根据提示完成以下操作

+
+ +
+

请在手机中打开Google Authenticator应用,输入6位动态码

+
+ +
+ + {% csrf_token %} + {% if 'otp_code' in form.errors %} +

{{ form.otp_code.errors.as_text }}

+ {% endif %} +
+ +
+ + + + {% trans "Can't provide security? Please contact the administrator" %} + + +
+
+

+

+
+
+
+
+
+
+ {% include '_copyright.html' %} +
+
+
+ + diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index 12a9af86e..ae192c82f 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -87,10 +87,18 @@ {% trans 'Role' %}: {{ user_object.get_role_display }} -{# #} -{# {% trans 'Enable OTP' %}:#} -{# {{ user_object.enable_otp|yesno:"Yes,No,Unknown"}}#} -{# #} + + {% trans 'Enable OTP' %}: + + {% if user_object.otp_force_enabled %} + {% trans 'Force enabled' %} + {% elif user_object.otp_enabled%} + {% trans 'Enabled' %} + {% else %} + {% trans 'Disabled' %} + {% endif %} + + {% trans 'Date expired' %}: {{ user_object.date_expired|date:"Y-m-j H:i:s" }} @@ -137,22 +145,23 @@ + + + + {% trans 'Force enabled OTP' %}: + +
+
+ + +
+
+
-{# #} -{# {% trans 'Enable OTP' %}:#} -{# #} -{#
#} -{#
#} -{# #} -{# #} -{#
#} -{#
#} -{#
#} -{# #} {% trans 'Send reset password mail' %}: @@ -277,19 +286,28 @@ $(document).ready(function() { success_message: success }); }) -{#.on('click', '#enable_otp', function() {#} -{# var the_url = "{% url 'api-users:user-detail' pk=user_object.id %}";#} -{# var checked = $(this).prop('checked');#} -{# var body = {#} -{# 'enable_otp': checked#} -{# };#} -{# var success = '{% trans "Update successfully!" %}';#} -{# APIUpdateAttr({#} -{# url: the_url,#} -{# body: JSON.stringify(body),#} -{# success_message: success#} -{# });#} -{# });#} +.on('click', '#force_enable_otp', function() { + var the_url = "{% url 'api-users:user-detail' pk=user_object.id %}"; + var checked = $(this).prop('checked'); + var otp_level; + var otp_secret_key; + if(checked){ + otp_level = 2 + }else{ + otp_level = 0; + otp_secret_key = ''; + } + var body = { + 'otp_level': otp_level, + 'otp_secret_key': otp_secret_key + }; + var success = '{% trans "Update successfully!" %}'; + APIUpdateAttr({ + url: the_url, + body: JSON.stringify(body), + success_message: success + }); + }) .on('click', '#btn_join_group', function() { if (Object.keys(jumpserver.nodes_selected).length === 0) { return false; diff --git a/apps/users/templates/users/user_otp_authentication.html b/apps/users/templates/users/user_otp_authentication.html new file mode 100644 index 000000000..6e13111ff --- /dev/null +++ b/apps/users/templates/users/user_otp_authentication.html @@ -0,0 +1,37 @@ +{% extends 'users/_base_otp.html' %} +{% load static %} +{% load i18n %} + +{% block content %} +
+

账号保护已开启,请根据提示完成以下操作

+ +

请在手机中打开Google Authenticator应用,输入6为动态码

+
+ +
+ {% csrf_token %} + {% if 'otp_code' in form.errors %} +

{{ form.otp_code.errors.as_text }}

+ {% endif %} + +
+ +
+ + +
+ + + +{% endblock %} + + diff --git a/apps/users/templates/users/user_otp_enable_bind.html b/apps/users/templates/users/user_otp_enable_bind.html new file mode 100644 index 000000000..e97f7cf9e --- /dev/null +++ b/apps/users/templates/users/user_otp_enable_bind.html @@ -0,0 +1,54 @@ +{% extends 'users/_base_otp.html' %} +{% load static %} +{% load i18n %} + +{% block content %} + +
+

使用手机 Google Authenticator 应用扫描以下二维码,获取6位验证码

+ + +
+ + +
+ {% csrf_token %} + +
+ +
+ + {% if 'otp_code' in form.errors %} +

{{ form.otp_code.errors.as_text }}

+ {% endif %} + + + +
+
+ + + + + + +{% endblock %} + diff --git a/apps/users/templates/users/user_otp_enable_install_app.html b/apps/users/templates/users/user_otp_enable_install_app.html new file mode 100644 index 000000000..a47075d70 --- /dev/null +++ b/apps/users/templates/users/user_otp_enable_install_app.html @@ -0,0 +1,32 @@ +{% extends 'users/_base_otp.html' %} +{% load i18n %} +{% load static %} + +{% block content %} +
+

请在手机端下载并安装 Google Authenticator 应用

+
+ +

Android手机下载

+
+ +
+ +

iPhone手机下载

+
+ +

+

安装完成后点击下一步进入绑定页面(如已安装,直接进入下一步)

+
+ + + + + +{% endblock %} + diff --git a/apps/users/templates/users/user_password_authentication.html b/apps/users/templates/users/user_password_authentication.html new file mode 100644 index 000000000..7603a1149 --- /dev/null +++ b/apps/users/templates/users/user_password_authentication.html @@ -0,0 +1,25 @@ +{% extends 'users/_base_otp.html' %} +{% load static %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} +
+ +
+ +
+ +
+ + {% if 'password' in form.errors %} +

{{ form.password.errors.as_text }}

+ {% endif %} + + + + +
+{% endblock %} + diff --git a/apps/users/templates/users/user_profile.html b/apps/users/templates/users/user_profile.html index f29e1427e..a924b9f02 100644 --- a/apps/users/templates/users/user_profile.html +++ b/apps/users/templates/users/user_profile.html @@ -65,7 +65,15 @@ {% trans 'OTP' %} - {{ user.otp_enabled|yesno:"Yes,No,Unkown" }} + + {% if user.otp_force_enabled %} + {% trans 'Force enable' %} + {% elif user.otp_enabled%} + {% trans 'Enable' %} + {% else %} + {% trans 'Disable' %} + {% endif %} + {% trans 'Public key' %} @@ -136,6 +144,28 @@ + + {% trans 'Update otp' %}: + + + {% trans 'Disable' %} + {% else %} + {% url 'users:user-otp-disable-authentication' %} + ">{% trans 'Disable' %} + {% endif %} + {% else %} + {% url 'users:user-otp-enable-authentication' %} + ">{% trans 'Enable' %} + {% endif %} + + + + {% trans 'Update SSH public key' %}: diff --git a/apps/users/urls/views_urls.py b/apps/users/urls/views_urls.py index b9d6788ee..2aaf4a9ae 100644 --- a/apps/users/urls/views_urls.py +++ b/apps/users/urls/views_urls.py @@ -10,6 +10,7 @@ urlpatterns = [ # Login view url(r'^login$', views.UserLoginView.as_view(), name='login'), url(r'^logout$', views.UserLogoutView.as_view(), name='logout'), + url(r'^login/otp$', views.UserLoginOtpView.as_view(), name='login-otp'), url(r'^password/forgot$', views.UserForgotPasswordView.as_view(), name='forgot-password'), url(r'^password/forgot/sendmail-success$', views.UserForgotPasswordSendmailSuccessView.as_view(), name='forgot-password-sendmail-success'), url(r'^password/reset$', views.UserResetPasswordView.as_view(), name='reset-password'), @@ -21,6 +22,11 @@ urlpatterns = [ url(r'^profile/password/update/$', views.UserPasswordUpdateView.as_view(), name='user-password-update'), url(r'^profile/pubkey/update/$', views.UserPublicKeyUpdateView.as_view(), name='user-pubkey-update'), url(r'^profile/pubkey/generate/$', views.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'), + url(r'^profile/otp/enable/authentication/$', views.UserOtpEnableAuthenticationView.as_view(), name='user-otp-enable-authentication'), + url(r'^profile/otp/enable/install-app/$', views.UserOtpEnableInstallAppView.as_view(), name='user-otp-enable-install-app'), + url(r'^profile/otp/enable/bind/$', views.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'), + url(r'^profile/otp/disable/authentication/$', views.UserOtpDisableAuthenticationView.as_view(), name='user-otp-disable-authentication'), + url(r'^profile/otp/settings-success/$', views.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'), # User view url(r'^user$', views.UserListView.as_view(), name='user-list'), @@ -34,7 +40,6 @@ urlpatterns = [ url(r'^user/(?P[0-9a-zA-Z\-]{36})/assets', views.UserGrantedAssetView.as_view(), name='user-granted-asset'), url(r'^user/(?P[0-9a-zA-Z\-]{36})/login-history', views.UserDetailView.as_view(), name='user-login-history'), - # User group view url(r'^user-group$', views.UserGroupListView.as_view(), name='user-group-list'), url(r'^user-group/(?P[0-9a-zA-Z\-]{36})$', views.UserGroupDetailView.as_view(), name='user-group-detail'), diff --git a/apps/users/utils.py b/apps/users/utils.py index c8a5b60a8..8113ec358 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -1,6 +1,8 @@ # ~*~ coding: utf-8 ~*~ # from __future__ import unicode_literals +import os +import pyotp import base64 import logging import uuid @@ -17,7 +19,6 @@ from common.tasks import send_mail_async from common.utils import reverse, get_object_or_none from .models import User, LoginLog - logger = logging.getLogger('jumpserver') @@ -163,7 +164,7 @@ def generate_token(request, user): remote_addr = request.META.get('REMOTE_ADDR', '') if not isinstance(remote_addr, bytes): remote_addr = remote_addr.encode("utf-8") - remote_addr = base64.b16encode(remote_addr) #.replace(b'=', '') + remote_addr = base64.b16encode(remote_addr) # .replace(b'=', '') token = cache.get('%s_%s' % (user.id, remote_addr)) if not token: token = uuid.uuid4().hex @@ -181,6 +182,16 @@ def validate_ip(ip): return False +def get_login_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') + if x_forwarded_for and x_forwarded_for[0]: + login_ip = x_forwarded_for[0] + else: + login_ip = request.META.get('REMOTE_ADDR', '') + + return login_ip + + def write_login_log(username, type='', ip='', user_agent=''): if not (ip and validate_ip(ip)): ip = ip[:15] @@ -211,3 +222,35 @@ def get_ip_city(ip, timeout=10): except ValueError: pass return city + + +def get_user(request): + if is_login(request): + user = request.user + else: + user = cache.get(request.session.session_key) + return user + + +def is_login(request): + return isinstance(request.user, User) + + +def redirect_user_first_login_or_index(request, redirect_field_name): + if request.user.is_first_login: + return reverse('users:user-first-login') + return request.POST.get( + redirect_field_name, + request.GET.get(redirect_field_name, reverse('index'))) + + +def generate_otp_uri(user, issuer="Jumpserver"): + otp_secret_key = base64.b32encode(os.urandom(10)).decode('utf-8') + cache.set('otp_secret_key', otp_secret_key, 300) + totp = pyotp.TOTP(otp_secret_key) + return totp.provisioning_uri(name=user.username, issuer_name=issuer) + + +def check_otp_code(otp_secret_key, otp_code): + totp = pyotp.TOTP(otp_secret_key) + return totp.verify(otp_code) diff --git a/apps/users/views/login.py b/apps/users/views/login.py index cd8ce8fca..bb18b28da 100644 --- a/apps/users/views/login.py +++ b/apps/users/views/login.py @@ -19,17 +19,18 @@ from django.views.generic.base import TemplateView from django.views.generic.edit import FormView from formtools.wizard.views import SessionWizardView from django.conf import settings +from django.core.cache import cache from common.utils import get_object_or_none from common.mixins import DatetimeSearchMixin, AdminUserRequiredMixin from ..models import User, LoginLog -from ..utils import send_reset_password_mail +from ..utils import send_reset_password_mail, check_otp_code , get_login_ip, redirect_user_first_login_or_index from ..tasks import write_login_log_async from .. import forms __all__ = [ - 'UserLoginView', 'UserLogoutView', + 'UserLoginView', 'UserLoginOtpView', 'UserLogoutView', 'UserForgotPasswordView', 'UserForgotPasswordSendmailSuccessView', 'UserResetPasswordView', 'UserResetPasswordSuccessView', 'UserFirstLoginView', 'LoginLogListView' @@ -53,27 +54,23 @@ class UserLoginView(FormView): def form_valid(self, form): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) - auth_login(self.request, form.get_user()) - x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') - - if x_forwarded_for and x_forwarded_for[0]: - login_ip = x_forwarded_for[0] - else: - login_ip = self.request.META.get('REMOTE_ADDR', '') - user_agent = self.request.META.get('HTTP_USER_AGENT', '') - write_login_log_async.delay( - self.request.user.username, type='W', - ip=login_ip, user_agent=user_agent - ) + cache.set(self.request.session.session_key, form.get_user(), 600) return redirect(self.get_success_url()) def get_success_url(self): - if self.request.user.is_first_login: - return reverse('users:user-first-login') + user = cache.get(self.request.session.session_key) - return self.request.POST.get( - self.redirect_field_name, - self.request.GET.get(self.redirect_field_name, reverse('index'))) + if user.otp_enabled and user.otp_secret_key: + # 1,2 & T + return reverse('users:login-otp') + elif user.otp_enabled and not user.otp_secret_key: + # 1,2 & F + return reverse('users:user-otp-enable-authentication') + elif not user.otp_enabled: + # 0 & T,F + auth_login(self.request, user) + self.write_login_log() + return redirect_user_first_login_or_index(self.request, self.redirect_field_name) def get_context_data(self, **kwargs): context = { @@ -82,6 +79,44 @@ class UserLoginView(FormView): kwargs.update(context) return super().get_context_data(**kwargs) + def write_login_log(self): + login_ip = get_login_ip(self.request) + user_agent = self.request.META.get('HTTP_USER_AGENT', '') + write_login_log_async.delay( + self.request.user.username, type='W', + ip=login_ip, user_agent=user_agent + ) + + +class UserLoginOtpView(FormView): + template_name = 'users/login_otp.html' + form_class = forms.UserCheckOtpCodeForm + redirect_field_name = 'next' + + def form_valid(self, form): + user = cache.get(self.request.session.session_key) + otp_code = form.cleaned_data.get('otp_code') + otp_secret_key = user.otp_secret_key + + if check_otp_code(otp_secret_key, otp_code): + auth_login(self.request, user) + self.write_login_log() + return redirect(self.get_success_url()) + else: + form.add_error('otp_code', _('Otp code invalid')) + return super().form_invalid(form) + + def get_success_url(self): + return redirect_user_first_login_or_index(self.request, self.redirect_field_name) + + def write_login_log(self): + login_ip = get_login_ip(self.request) + user_agent = self.request.META.get('HTTP_USER_AGENT', '') + write_login_log_async.delay( + self.request.user.username, type='W', + ip=login_ip, user_agent=user_agent + ) + @method_decorator(never_cache, name='dispatch') class UserLogoutView(TemplateView): diff --git a/apps/users/views/user.py b/apps/users/views/user.py index 1d68eb81d..af954ba10 100644 --- a/apps/users/views/user.py +++ b/apps/users/views/user.py @@ -11,6 +11,7 @@ from io import StringIO from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth import authenticate, login as auth_login from django.contrib.messages.views import SuccessMessageMixin from django.core.cache import cache from django.http import HttpResponse, JsonResponse @@ -34,9 +35,9 @@ from common.mixins import JSONResponseMixin from common.utils import get_logger, get_object_or_none, is_uuid, ssh_key_gen from .. import forms from ..models import User, UserGroup -from ..utils import AdminUserRequiredMixin +from ..utils import AdminUserRequiredMixin, generate_otp_uri, check_otp_code, get_user, is_login from ..signals import post_user_create - +from ..tasks import write_login_log_async __all__ = [ 'UserListView', 'UserCreateView', 'UserDetailView', @@ -46,6 +47,9 @@ __all__ = [ 'UserProfileUpdateView', 'UserPasswordUpdateView', 'UserPublicKeyUpdateView', 'UserBulkUpdateView', 'UserPublicKeyGenerateView', + 'UserOtpEnableAuthenticationView', 'UserOtpEnableInstallAppView', + 'UserOtpEnableBindView', 'UserOtpSettingsSuccessView', + 'UserOtpDisableAuthenticationView', ] logger = get_logger(__name__) @@ -380,6 +384,7 @@ class UserPublicKeyUpdateView(LoginRequiredMixin, UpdateView): class UserPublicKeyGenerateView(LoginRequiredMixin, View): + def get(self, request, *args, **kwargs): private, public = ssh_key_gen(username=request.user.username, hostname='jumpserver') request.user.public_key = public @@ -389,3 +394,125 @@ class UserPublicKeyGenerateView(LoginRequiredMixin, View): response['Content-Disposition'] = 'attachment; filename={}'.format(filename) return response + +class UserOtpEnableAuthenticationView(FormView): + template_name = 'users/user_password_authentication.html' + form_class = forms.UserCheckPasswordForm + + def get_form(self, form_class=None): + form = super().get_form(form_class=form_class) + form['username'].initial = get_user(self.request).username + return form + + def get_context_data(self, **kwargs): + context = { + 'user': get_user(self.request) + } + kwargs.update(context) + return super().get_context_data(**kwargs) + + def form_valid(self, form): + password = form.cleaned_data.get('password') + user = get_user(self.request) + user = authenticate(username=user.username, password=password) + if not user: + form.add_error("password", _("Password invalid")) + return self.form_invalid(form) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse('users:user-otp-enable-install-app') + + +class UserOtpEnableInstallAppView(TemplateView): + template_name = 'users/user_otp_enable_install_app.html' + + def get_context_data(self, **kwargs): + context = { + 'user': get_user(self.request) + } + kwargs.update(context) + return super().get_context_data(**kwargs) + + +class UserOtpEnableBindView(TemplateView, FormView): + template_name = 'users/user_otp_enable_bind.html' + form_class = forms.UserCheckOtpCodeForm + success_url = reverse_lazy('users:user-otp-settings-success') + + def get_context_data(self, **kwargs): + context = { + 'otp_uri': generate_otp_uri(user=get_user(self.request)), + 'user': get_user(self.request) + } + kwargs.update(context) + return super().get_context_data(**kwargs) + + def form_valid(self, form): + otp_code = form.cleaned_data.get('otp_code') + otp_secret_key = cache.get('otp_secret_key') + + if check_otp_code(otp_secret_key, otp_code): + self.save_otp(otp_secret_key) + return super().form_valid(form) + + else: + form.add_error("otp_code", _("Otp code invalid")) + return self.form_invalid(form) + + def save_otp(self, otp_secret_key): + user = get_user(self.request) + user.enable_otp() + user.otp_secret_key = otp_secret_key + user.save() + + +class UserOtpDisableAuthenticationView(FormView): + template_name = 'users/user_otp_authentication.html' + form_class = forms.UserCheckOtpCodeForm + success_url = reverse_lazy('users:user-otp-settings-success') + + def form_valid(self, form): + user = self.request.user + otp_code = form.cleaned_data.get('otp_code') + otp_secret_key = user.otp_secret_key + + if check_otp_code(otp_secret_key, otp_code): + user.disable_otp() + user.save() + return super().form_valid(form) + else: + form.add_error('otp_code', _('Otp code invalid')) + return super().form_invalid(form) + + +class UserOtpSettingsSuccessView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if is_login(request): + auth_logout(request) + return response + + def get_context_data(self, **kwargs): + title, describe = self.get_title_describe() + context = { + 'title': title, + 'messages': describe, + 'interval': 1, + 'redirect_url': reverse('users:login'), + 'auto_redirect': True, + } + kwargs.update(context) + return super().get_context_data(**kwargs) + + def get_title_describe(self): + user = get_user(self.request) + title = _('OTP enable success') + describe = _('OTP enable success, return login page') + if not user.otp_enabled: + title = _('OTP disable success') + describe = _('OTP disable success, return login page') + + return title, describe