@@ -73,6 +73,9 @@ <h4 class="mb-1">登录</h4>
7373 < li class ="nav-item ">
7474 < button class ="nav-link " data-tab ="bookmarklet "> 书签登录(桌面端)</ button >
7575 </ li >
76+ < li class ="nav-item ">
77+ < button class ="nav-link " data-tab ="sso "> 统一认证登录</ button >
78+ </ li >
7679 </ ul >
7780
7881 <!-- Tab: Manual (default) -->
@@ -216,6 +219,22 @@ <h6 class="card-title">第二步:在 XMOJ 上点击书签</h6>
216219 </ div >
217220 </ div >
218221 </ div >
222+
223+ <!-- Tab: SSO Login -->
224+ < div id ="tab-sso " style ="display:none; ">
225+ < div class ="card border-0 shadow-sm ">
226+ < div class ="card-body ">
227+ < h6 class ="card-title mb-2 "> 统一认证中心登录</ h6 >
228+ < p class ="card-text small text-body-secondary mb-3 ">
229+ 点击下方按钮跳转至统一认证中心授权,授权成功后将自动返回并完成登录。
230+ </ p >
231+ < button class ="btn btn-primary w-100 " id ="btn-sso-login "> 前往统一认证中心授权</ button >
232+ < p class ="small text-body-secondary mt-2 mb-0 ">
233+ 授权地址:sso.xmoj-bbs.me,回调地址:xmoj-bbs.me/messages.html
234+ </ p >
235+ </ div >
236+ </ div >
237+ </ div >
219238</ div >
220239
221240<!-- ==================== SCREEN: MAIL LIST ==================== -->
@@ -377,6 +396,11 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
377396const SCROLL_THRESHOLD = 80 ;
378397const MAX_IMAGE_BYTES = 5 * 1024 * 1024 ;
379398const PREVIEW_LEN = 60 ;
399+ const OAUTH_AUTHORIZE_URL = 'https://sso.xmoj-bbs.me/authorize' ;
400+ const OAUTH_CLIENT_ID = 'app_0f4cbbddfa42c0c4576cba8f7598d0a2' ;
401+ const OAUTH_SCOPE = 'openid profile email xmoj_profile' ;
402+ const OAUTH_REDIRECT_URI = 'https://xmoj-bbs.me/messages.html' ;
403+ const STORAGE_OAUTH_STATE = 'xmoj-msg-oauth-state' ;
380404
381405// ── State ──────────────────────────────────────────────────────────────────
382406let currentUser = null ; // { username, phpsessid }
@@ -931,6 +955,7 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
931955 } ) ;
932956 document . getElementById ( 'tab-bookmarklet' ) . style . display = tab === 'bookmarklet' ? '' : 'none' ;
933957 document . getElementById ( 'tab-manual' ) . style . display = tab === 'manual' ? '' : 'none' ;
958+ document . getElementById ( 'tab-sso' ) . style . display = tab === 'sso' ? '' : 'none' ;
934959}
935960
936961document . querySelectorAll ( '#loginTabs .nav-link' ) . forEach ( function ( btn ) {
@@ -939,6 +964,89 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
939964 } ) ;
940965} ) ;
941966
967+ function generateUuidV4 ( ) {
968+ if ( window . crypto && window . crypto . randomUUID ) return window . crypto . randomUUID ( ) ;
969+ if ( window . crypto && window . crypto . getRandomValues ) {
970+ var bytes = new Uint8Array ( 16 ) ;
971+ window . crypto . getRandomValues ( bytes ) ;
972+ bytes [ 6 ] = ( bytes [ 6 ] & 0x0f ) | 0x40 ;
973+ bytes [ 8 ] = ( bytes [ 8 ] & 0x3f ) | 0x80 ;
974+ var hex = Array . from ( bytes ) . map ( function ( b ) { return b . toString ( 16 ) . padStart ( 2 , '0' ) ; } ) . join ( '' ) ;
975+ return [ hex . slice ( 0 , 8 ) , hex . slice ( 8 , 12 ) , hex . slice ( 12 , 16 ) , hex . slice ( 16 , 20 ) , hex . slice ( 20 ) ] . join ( '-' ) ;
976+ }
977+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' . replace ( / [ x y ] / g, function ( c ) {
978+ var r = Math . random ( ) * 16 | 0 ;
979+ var v = c === 'x' ? r : ( r & 0x3 | 0x8 ) ;
980+ return v . toString ( 16 ) ;
981+ } ) ;
982+ }
983+
984+ function startSsoLogin ( ) {
985+ var state = generateUuidV4 ( ) + '.' + generateUuidV4 ( ) ;
986+ localStorage . setItem ( STORAGE_OAUTH_STATE , state ) ;
987+ var params = new URLSearchParams ( {
988+ response_type : 'code' ,
989+ client_id : OAUTH_CLIENT_ID ,
990+ redirect_uri : OAUTH_REDIRECT_URI ,
991+ scope : OAUTH_SCOPE ,
992+ state : state
993+ } ) ;
994+ location . href = OAUTH_AUTHORIZE_URL + '?' + params . toString ( ) ;
995+ }
996+
997+ async function exchangeSsoCode ( code , state ) {
998+ var res = await fetch ( API_BASE + 'ExchangeSSOCode' , {
999+ method : 'POST' ,
1000+ headers : { 'Content-Type' : 'application/json' } ,
1001+ body : JSON . stringify ( {
1002+ Code : code ,
1003+ State : state ,
1004+ RedirectURI : OAUTH_REDIRECT_URI ,
1005+ ClientID : OAUTH_CLIENT_ID
1006+ } )
1007+ } ) ;
1008+ if ( ! res . ok ) throw new Error ( 'HTTP ' + res . status ) ;
1009+ return res . json ( ) ;
1010+ }
1011+
1012+ async function handleSsoCallback ( ) {
1013+ var url = new URL ( location . href ) ;
1014+ var code = url . searchParams . get ( 'code' ) ;
1015+ var state = url . searchParams . get ( 'state' ) ;
1016+ if ( ! code ) return false ;
1017+
1018+ var expectedState = localStorage . getItem ( STORAGE_OAUTH_STATE ) ;
1019+ if ( ! state || ! expectedState || state !== expectedState ) {
1020+ showToast ( '统一认证登录失败:state 校验失败' , 'danger' ) ;
1021+ url . searchParams . delete ( 'code' ) ;
1022+ url . searchParams . delete ( 'state' ) ;
1023+ history . replaceState ( null , '' , url . pathname + ( url . searchParams . toString ( ) ? '?' + url . searchParams . toString ( ) : '' ) ) ;
1024+ return false ;
1025+ }
1026+
1027+ try {
1028+ var result = await exchangeSsoCode ( code , state ) ;
1029+ if ( ! result || ! result . Success || ! result . Data ) {
1030+ throw new Error ( ( result && result . Message ) || '后端未返回有效登录数据' ) ;
1031+ }
1032+ if ( ! result . Data . Username || ! result . Data . SessionID ) {
1033+ throw new Error ( '返回数据缺少 Username 或 SessionID' ) ;
1034+ }
1035+
1036+ localStorage . removeItem ( STORAGE_OAUTH_STATE ) ;
1037+ saveSession ( result . Data . Username , result . Data . SessionID ) ;
1038+ url . searchParams . delete ( 'code' ) ;
1039+ url . searchParams . delete ( 'state' ) ;
1040+ history . replaceState ( null , '' , url . pathname + ( url . searchParams . toString ( ) ? '?' + url . searchParams . toString ( ) : '' ) ) ;
1041+ showToast ( '统一认证登录成功' , 'success' ) ;
1042+ onLoggedIn ( ) ;
1043+ return true ;
1044+ } catch ( err ) {
1045+ showToast ( '统一认证登录失败:' + err . message , 'danger' ) ;
1046+ return false ;
1047+ }
1048+ }
1049+
9421050// ── Event Wiring ───────────────────────────────────────────────────────────
9431051document . getElementById ( 'btn-manual-login' ) . addEventListener ( 'click' , function ( ) {
9441052 var username = document . getElementById ( 'input-username' ) . value . trim ( ) ;
@@ -948,6 +1056,8 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
9481056 onLoggedIn ( ) ;
9491057} ) ;
9501058
1059+ document . getElementById ( 'btn-sso-login' ) . addEventListener ( 'click' , startSsoLogin ) ;
1060+
9511061document . getElementById ( 'btn-logout' ) . addEventListener ( 'click' , logout ) ;
9521062
9531063document . getElementById ( 'btn-refresh-list' ) . addEventListener ( 'click' , loadMailList ) ;
@@ -1034,7 +1144,7 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
10341144} ) ;
10351145
10361146// ── Boot ───────────────────────────────────────────────────────────────────
1037- ( function init ( ) {
1147+ ( async function init ( ) {
10381148 initTheme ( ) ;
10391149 initBookmarklet ( ) ;
10401150
@@ -1050,6 +1160,8 @@ <h5 class="modal-title" id="user-info-modal-title">用户信息</h5>
10501160 return ;
10511161 }
10521162
1163+ if ( await handleSsoCallback ( ) ) return ;
1164+
10531165 if ( loadSession ( ) ) {
10541166 onLoggedIn ( ) ;
10551167 } else {
0 commit comments