├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── config.ts ├── dnd.ts └── openssl.cnf ├── bootstrap.php ├── callbacks.php ├── lang ├── en │ ├── config.yml │ ├── dnd.yml │ ├── exceptions.yml │ ├── front-end.yml │ ├── log.yml │ └── middleware.yml └── zh_CN │ ├── config.yml │ ├── dnd.yml │ ├── exceptions.yml │ ├── front-end.yml │ ├── log.yml │ └── middleware.yml ├── package.json ├── routes.php ├── src ├── Controllers │ ├── AuthController.php │ ├── ConfigController.php │ ├── ProfileController.php │ └── SessionController.php ├── Exceptions │ ├── ForbiddenOperationException.php │ ├── IllegalArgumentException.php │ ├── NotFoundException.php │ └── YggdrasilException.php ├── Middleware │ ├── AddApiIndicationHeader.php │ ├── CheckContentType.php │ └── Throttle.php ├── Models │ ├── Profile.php │ └── Token.php └── Utils │ ├── UUID.php │ └── helpers.php └── views ├── config.twig ├── dnd.twig └── log.twig /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## v4.1.4 4 | 5 | - 修复用户中心不能显示「快速配置」板块的问题 6 | 7 | ## v4.0.0 8 | 9 | - 支持 Blessing Skin v5 10 | 11 | ## v3.5.0 12 | 13 | - 支持「论坛对接」插件 14 | 15 | ## v3.4.1 16 | 17 | - 修复刷新令牌后过期时间未更新的问题 18 | 19 | ## v3.3.0 20 | 21 | - 允许经过验证的正版用户使用任意启动器进行进服 22 | - 对于经过验证的正版用户支持从 Mojang 获取皮肤和披风 23 | 24 | ## v3.2.1 25 | 26 | - 修复「关闭日志输出」功能 27 | 28 | ## v3.2.0 29 | 30 | - 修复可能导致无法识别皮肤地址的问题 31 | - 修复「关闭日志输出」选项未起作用的问题 32 | 33 | ## v3.0.0 34 | 35 | - 完成对 Blessing Skin v4.0.0 的支持和适配(不再支持 BS v3 及以下版本) 36 | - 进一步地完善对 Yggdrasil API 规范的实现 37 | - 移除了「导入 UUID 映射」的功能 38 | - 移除了「显示近期活动」的功能 39 | 40 | ## v3.0.0-beta.0 41 | - 支持 Blessing Skin v4.0.0 42 | 43 | ## v2.1.10 44 | - 支持 API 地址指示 (ALI) [#18](https://github.com/yushijinhun/authlib-injector/issues/18) 45 | - 支持在元数据中包含验证服务器网址 [#25](https://github.com/yushijinhun/authlib-injector/issues/25) 46 | - 不再向用户展示 `hasJoined` 请求的来源 IP 47 | 48 | ## v2.1.9 49 | - 修复可能的 XSS 漏洞 50 | 51 | ## v2.1.8 52 | - 阻止被封禁的用户使用未过期的 Access Token 进入服务器 53 | 54 | ## v2.1.7 55 | - 修复用户修改角色名后 UUID 改变的问题 [#9](https://github.com/bs-community/blessing-skin-plugins/issues/9)(可能造成游戏内玩家数据重置) 56 | - 选择「与离线模式一致」的 UUID 生成算法时,角色名修改后 UUID 依然会改变 57 | 58 | ## v2.1.6 59 | - 支持最新版 BS 中的邮箱验证机制 60 | - 未验证邮箱的用户无法通过 Yggdrasil API 登录 61 | 62 | ## v2.1.5 63 | - 修复 FireFox 下「拖拽添加 Yggdrasil 认证服务器」不工作的问题 64 | - 将用户中心首页的「外置登录系统 - 最近活动」板块标题更改为「你的近期活动」 65 | - 修复与「邮箱验证插件」不兼容的问题 66 | 67 | ## v2.1.4 68 | - 记录活动日志(`登录认证`、`进入服务器` 等)至数据库 `ygg_log` 表 69 | - 管理后台新增「外置登录系统活动日志」查看页面 70 | - 用户中心首页添加「最近活动」板块(可在插件配置页关闭) 71 | - 修复某些情况下 DnD 拖拽添加 Yggdrasil 认证服务器不工作的问题 72 | - 修复玩家进服验证相关的安全问题 73 | 74 | ## v2.1.3 75 | - 修复 `/authserver/validate` 错误的返回值导致启动器无法刷新令牌的问题 76 | - 修复 `/authserver/authenticate` 与 `/authserver/signout` 请求频率限制失效的问题 77 | - 修复 `/api/profiles/minecraft` 不能正确处理重复的查询的问题 78 | - 修复签到后用户中心「快速配置启动器」板块消失的问题 79 | - 修复默认的令牌过期时间过短的问题 80 | - 不再推荐普通用户使用「导入服务器 `usercache.json`」功能 81 | - 未指定 `unsigned` 参数请求玩家 profile 时响应不再默认包含数据签名 82 | - 添加 Parcel 前端构建流程 83 | 84 | ## v2.1.2 85 | - 用户中心首页新增「快速配置启动器」板块(可在插件配置页关闭) 86 | - 支持 [DnD 拖拽方式添加 Yggdrasil 认证服务器](http://t.cn/RdKTDAz) 87 | 88 | ## v2.1.1 89 | - 初次使用时自动生成私钥 90 | - 在插件配置页显示当前站点的 API Root 91 | - 将 `uuid_algorithm` 配置项重命名为 `ygg_uuid_algorithm` 92 | 93 | ## v2.1.0 94 | - 新增「一键生成私钥」的功能 95 | - 新增「修改 UUID 生成算法」的选项,可改为与 MC 盗版用户 UUID 生成算法一致 96 | - 新增「导入 MC 服务器 UUID 映射表(`usercache.json`)」的功能 97 | - 增强兼容性,减少原盗版服迁移至本方案后出现 UUID 冲突的可能性,详见 http://t.cn/RrAK4F8 98 | 99 | ## v2.0.2 100 | - 修复了用户邮箱大小写敏感的问题 101 | - 上述问题可能导致某些用户无法进入游戏,提示「未查找到有效的登录信息,请重新登录」 102 | 103 | ## v2.0.1 104 | - 单独记录本插件的日志到 `storage/logs/yggdrasil.log` 文件中 105 | - 加入了超级详细的日志,几乎每个操作都有日志记录,详细到弱智都能 DEBUG 106 | - 所以注意生产环境不要开启插件配置中的「记录详细日志」选项哦 107 | - 修复批量查找 profile 的问题(citizens 等 NPC 插件会用到这个) 108 | - 强制使用后台中填写的「站点地址」生成材质 URL(避免 BungeeCord 上的奇葩问题) 109 | - 现在「插件设置」中你只需要填写私钥即可,公钥会根据私钥自动生成 110 | 111 | ## v2.0.0 112 | - 重构插件,支持 authlib-injector 113 | - 注意,此版本以后不再支持 authlib-agent 114 | 115 | ## v1.1.4 116 | - 加入「记录详细日志」的选项 117 | 118 | ## v1.1.3 119 | - 加入中文翻译 120 | - 禁止被主站封禁的角色通过 Yggdrasil API 认证 121 | 122 | ## v1.1.1 123 | - 添加材质的数字签名支持 124 | - 修复一些 BUG 125 | 126 | ## v1.1.0 127 | - 当用户只有一个角色时自动使用该角色登录 128 | 129 | ## v1.0.0 130 | - 最初版本 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present The Blessing Skin Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yggdrasil API for Blessing Skin 2 | 3 | 本插件基本实现了 [Yggdrasil API 规范](https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil%20%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83),可与 [authlib-injector](https://github.com/to2mbn/authlib-injector) 等 authlib hook 配合使用实现外置登录系统。 4 | 5 | ## API 路由 6 | 7 | ``` 8 | routes.php 9 | 10 | # Authentication 11 | POST /api/yggdrasil/authserver/authenticate 12 | POST /api/yggdrasil/authserver/refresh 13 | POST /api/yggdrasil/authserver/validate 14 | POST /api/yggdrasil/authserver/invalidate 15 | POST /api/yggdrasil/authserver/signout 16 | 17 | # Session 18 | POST /api/yggdrasil/sessionserver/session/minecraft/join 19 | GET /api/yggdrasil/sessionserver/session/minecraft/hasJoined 20 | 21 | # Profiles 22 | GET /api/yggdrasil/sessionserver/session/minecraft/profile/{uuid} 23 | POST /api/yggdrasil/api/profiles/minecraft 24 | ``` 25 | 26 | ## 使用方法 27 | 28 | 请参阅本项目 [Wiki](https://github.com/bs-community/yggdrasil-api/wiki)。 29 | 30 | ## 版本说明 31 | 32 | 本插件的更新日志可以在这里查看:[CHANGELOG](https://github.com/bs-community/yggdrasil-api/blob/master/CHANGELOG.md)。 33 | 34 | 注意,v2.0.0 版本之后的插件不再支持 [authlib-agent](https://github.com/yushijinhun/authlib-agent)。 35 | -------------------------------------------------------------------------------- /assets/config.ts: -------------------------------------------------------------------------------- 1 | document 2 | .querySelector('[name=generate-key]')! 3 | .addEventListener('click', async () => { 4 | type Ok = { code: 0; key: string } 5 | type Err = { code: 1; message: string } 6 | 7 | const response: Ok | Err = await blessing.fetch.post( 8 | '/admin/plugins/config/yggdrasil-api/generate', 9 | ) 10 | 11 | if (response.code === 0) { 12 | blessing.notify.toast.success( 13 | trans('yggdrasil-api.key-generated'), 14 | ) 15 | 16 | document.querySelector('td.value textarea')!.value = 17 | response.key 18 | const form = document.querySelector('input[value=keypair]')! 19 | .parentElement as HTMLFormElement 20 | form.submit() 21 | } else { 22 | blessing.notify.toast.error(response.message) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /assets/dnd.ts: -------------------------------------------------------------------------------- 1 | document.body.addEventListener('dragstart', (event) => { 2 | if (!event.target) { 3 | return 4 | } 5 | 6 | const target = event.target as HTMLElement 7 | 8 | if (target.id === 'ygg-dnd-button') { 9 | const uri = 10 | 'authlib-injector:yggdrasil-server:' + 11 | encodeURIComponent(target.dataset.clipboardText!) 12 | 13 | if (event.dataTransfer) { 14 | event.dataTransfer.setData('text/plain', uri) 15 | event.dataTransfer.dropEffect = 'copy' 16 | } 17 | } 18 | }) 19 | 20 | document 21 | .querySelector('#ygg-dnd-button') 22 | ?.addEventListener('click', (event) => { 23 | const target = event.target as HTMLButtonElement 24 | const content = target.dataset.clipboardText! 25 | 26 | const input = document.createElement('input') 27 | input.style.visibility = 'none' 28 | input.value = content 29 | document.body.appendChild(input) 30 | input.select() 31 | document.execCommand('copy') 32 | 33 | input.remove() 34 | const originalContent = target.textContent 35 | target.disabled = true 36 | target.innerHTML = `${trans( 37 | 'yggdrasil-api.copied', 38 | )}` 39 | 40 | setTimeout(() => { 41 | target.textContent = originalContent 42 | target.disabled = false 43 | }, 1000) 44 | }) 45 | -------------------------------------------------------------------------------- /assets/openssl.cnf: -------------------------------------------------------------------------------- 1 | # 2 | # OpenSSL example configuration file. 3 | # This is mostly being used for generation of certificate requests. 4 | # 5 | 6 | # This definition stops the following lines choking if HOME isn't 7 | # defined. 8 | HOME = . 9 | RANDFILE = $ENV::HOME/.rnd 10 | 11 | # Extra OBJECT IDENTIFIER info: 12 | #oid_file = $ENV::HOME/.oid 13 | oid_section = new_oids 14 | 15 | # To use this configuration file with the "-extfile" option of the 16 | # "openssl x509" utility, name here the section containing the 17 | # X.509v3 extensions to use: 18 | # extensions = 19 | # (Alternatively, use a configuration file that has only 20 | # X.509v3 extensions in its main [= default] section.) 21 | 22 | [ new_oids ] 23 | 24 | # We can add new OIDs in here for use by 'ca', 'req' and 'ts'. 25 | # Add a simple OID like this: 26 | # testoid1=1.2.3.4 27 | # Or use config file substitution like this: 28 | # testoid2=${testoid1}.5.6 29 | 30 | # Policies used by the TSA examples. 31 | tsa_policy1 = 1.2.3.4.1 32 | tsa_policy2 = 1.2.3.4.5.6 33 | tsa_policy3 = 1.2.3.4.5.7 34 | 35 | #################################################################### 36 | [ ca ] 37 | default_ca = CA_default # The default ca section 38 | 39 | #################################################################### 40 | [ CA_default ] 41 | 42 | dir = ./demoCA # Where everything is kept 43 | certs = $dir/certs # Where the issued certs are kept 44 | crl_dir = $dir/crl # Where the issued crl are kept 45 | database = $dir/index.txt # database index file. 46 | #unique_subject = no # Set to 'no' to allow creation of 47 | # several certs with same subject. 48 | new_certs_dir = $dir/newcerts # default place for new certs. 49 | 50 | certificate = $dir/cacert.pem # The CA certificate 51 | serial = $dir/serial # The current serial number 52 | crlnumber = $dir/crlnumber # the current crl number 53 | # must be commented out to leave a V1 CRL 54 | crl = $dir/crl.pem # The current CRL 55 | private_key = $dir/private/cakey.pem# The private key 56 | RANDFILE = $dir/private/.rand # private random number file 57 | 58 | x509_extensions = usr_cert # The extensions to add to the cert 59 | 60 | # Comment out the following two lines for the "traditional" 61 | # (and highly broken) format. 62 | name_opt = ca_default # Subject Name options 63 | cert_opt = ca_default # Certificate field options 64 | 65 | # Extension copying option: use with caution. 66 | # copy_extensions = copy 67 | 68 | # Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs 69 | # so this is commented out by default to leave a V1 CRL. 70 | # crlnumber must also be commented out to leave a V1 CRL. 71 | # crl_extensions = crl_ext 72 | 73 | default_days = 365 # how long to certify for 74 | default_crl_days= 30 # how long before next CRL 75 | default_md = default # use public key default MD 76 | preserve = no # keep passed DN ordering 77 | 78 | # A few difference way of specifying how similar the request should look 79 | # For type CA, the listed attributes must be the same, and the optional 80 | # and supplied fields are just that :-) 81 | policy = policy_match 82 | 83 | # For the CA policy 84 | [ policy_match ] 85 | countryName = match 86 | stateOrProvinceName = match 87 | organizationName = match 88 | organizationalUnitName = optional 89 | commonName = supplied 90 | emailAddress = optional 91 | 92 | # For the 'anything' policy 93 | # At this point in time, you must list all acceptable 'object' 94 | # types. 95 | [ policy_anything ] 96 | countryName = optional 97 | stateOrProvinceName = optional 98 | localityName = optional 99 | organizationName = optional 100 | organizationalUnitName = optional 101 | commonName = supplied 102 | emailAddress = optional 103 | 104 | #################################################################### 105 | [ req ] 106 | default_bits = 2048 107 | default_keyfile = privkey.pem 108 | distinguished_name = req_distinguished_name 109 | attributes = req_attributes 110 | x509_extensions = v3_ca # The extensions to add to the self signed cert 111 | 112 | # Passwords for private keys if not present they will be prompted for 113 | # input_password = secret 114 | # output_password = secret 115 | 116 | # This sets a mask for permitted string types. There are several options. 117 | # default: PrintableString, T61String, BMPString. 118 | # pkix : PrintableString, BMPString (PKIX recommendation before 2004) 119 | # utf8only: only UTF8Strings (PKIX recommendation after 2004). 120 | # nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings). 121 | # MASK:XXXX a literal mask value. 122 | # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings. 123 | string_mask = utf8only 124 | 125 | # req_extensions = v3_req # The extensions to add to a certificate request 126 | 127 | [ req_distinguished_name ] 128 | countryName = Country Name (2 letter code) 129 | countryName_default = AU 130 | countryName_min = 2 131 | countryName_max = 2 132 | 133 | stateOrProvinceName = State or Province Name (full name) 134 | stateOrProvinceName_default = Some-State 135 | 136 | localityName = Locality Name (eg, city) 137 | 138 | 0.organizationName = Organization Name (eg, company) 139 | 0.organizationName_default = Internet Widgits Pty Ltd 140 | 141 | # we can do this but it is not needed normally :-) 142 | #1.organizationName = Second Organization Name (eg, company) 143 | #1.organizationName_default = World Wide Web Pty Ltd 144 | 145 | organizationalUnitName = Organizational Unit Name (eg, section) 146 | #organizationalUnitName_default = 147 | 148 | commonName = Common Name (e.g. server FQDN or YOUR name) 149 | commonName_max = 64 150 | 151 | emailAddress = Email Address 152 | emailAddress_max = 64 153 | 154 | # SET-ex3 = SET extension number 3 155 | 156 | [ req_attributes ] 157 | challengePassword = A challenge password 158 | challengePassword_min = 4 159 | challengePassword_max = 20 160 | 161 | unstructuredName = An optional company name 162 | 163 | [ usr_cert ] 164 | 165 | # These extensions are added when 'ca' signs a request. 166 | 167 | # This goes against PKIX guidelines but some CAs do it and some software 168 | # requires this to avoid interpreting an end user certificate as a CA. 169 | 170 | basicConstraints=CA:FALSE 171 | 172 | # Here are some examples of the usage of nsCertType. If it is omitted 173 | # the certificate can be used for anything *except* object signing. 174 | 175 | # This is OK for an SSL server. 176 | # nsCertType = server 177 | 178 | # For an object signing certificate this would be used. 179 | # nsCertType = objsign 180 | 181 | # For normal client use this is typical 182 | # nsCertType = client, email 183 | 184 | # and for everything including object signing: 185 | # nsCertType = client, email, objsign 186 | 187 | # This is typical in keyUsage for a client certificate. 188 | # keyUsage = nonRepudiation, digitalSignature, keyEncipherment 189 | 190 | # This will be displayed in Netscape's comment listbox. 191 | nsComment = "OpenSSL Generated Certificate" 192 | 193 | # PKIX recommendations harmless if included in all certificates. 194 | subjectKeyIdentifier=hash 195 | authorityKeyIdentifier=keyid,issuer 196 | 197 | # This stuff is for subjectAltName and issuerAltname. 198 | # Import the email address. 199 | # subjectAltName=email:copy 200 | # An alternative to produce certificates that aren't 201 | # deprecated according to PKIX. 202 | # subjectAltName=email:move 203 | 204 | # Copy subject details 205 | # issuerAltName=issuer:copy 206 | 207 | #nsCaRevocationUrl = http://www.domain.dom/ca-crl.pem 208 | #nsBaseUrl 209 | #nsRevocationUrl 210 | #nsRenewalUrl 211 | #nsCaPolicyUrl 212 | #nsSslServerName 213 | 214 | # This is required for TSA certificates. 215 | # extendedKeyUsage = critical,timeStamping 216 | 217 | [ v3_req ] 218 | 219 | # Extensions to add to a certificate request 220 | 221 | basicConstraints = CA:FALSE 222 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 223 | 224 | [ v3_ca ] 225 | 226 | 227 | # Extensions for a typical CA 228 | 229 | 230 | # PKIX recommendation. 231 | 232 | subjectKeyIdentifier=hash 233 | 234 | authorityKeyIdentifier=keyid:always,issuer 235 | 236 | basicConstraints = critical,CA:true 237 | 238 | # Key usage: this is typical for a CA certificate. However since it will 239 | # prevent it being used as an test self-signed certificate it is best 240 | # left out by default. 241 | # keyUsage = cRLSign, keyCertSign 242 | 243 | # Some might want this also 244 | # nsCertType = sslCA, emailCA 245 | 246 | # Include email address in subject alt name: another PKIX recommendation 247 | # subjectAltName=email:copy 248 | # Copy issuer details 249 | # issuerAltName=issuer:copy 250 | 251 | # DER hex encoding of an extension: beware experts only! 252 | # obj=DER:02:03 253 | # Where 'obj' is a standard or added object 254 | # You can even override a supported extension: 255 | # basicConstraints= critical, DER:30:03:01:01:FF 256 | 257 | [ crl_ext ] 258 | 259 | # CRL extensions. 260 | # Only issuerAltName and authorityKeyIdentifier make any sense in a CRL. 261 | 262 | # issuerAltName=issuer:copy 263 | authorityKeyIdentifier=keyid:always 264 | 265 | [ proxy_cert_ext ] 266 | # These extensions should be added when creating a proxy certificate 267 | 268 | # This goes against PKIX guidelines but some CAs do it and some software 269 | # requires this to avoid interpreting an end user certificate as a CA. 270 | 271 | basicConstraints=CA:FALSE 272 | 273 | # Here are some examples of the usage of nsCertType. If it is omitted 274 | # the certificate can be used for anything *except* object signing. 275 | 276 | # This is OK for an SSL server. 277 | # nsCertType = server 278 | 279 | # For an object signing certificate this would be used. 280 | # nsCertType = objsign 281 | 282 | # For normal client use this is typical 283 | # nsCertType = client, email 284 | 285 | # and for everything including object signing: 286 | # nsCertType = client, email, objsign 287 | 288 | # This is typical in keyUsage for a client certificate. 289 | # keyUsage = nonRepudiation, digitalSignature, keyEncipherment 290 | 291 | # This will be displayed in Netscape's comment listbox. 292 | nsComment = "OpenSSL Generated Certificate" 293 | 294 | # PKIX recommendations harmless if included in all certificates. 295 | subjectKeyIdentifier=hash 296 | authorityKeyIdentifier=keyid,issuer 297 | 298 | # This stuff is for subjectAltName and issuerAltname. 299 | # Import the email address. 300 | # subjectAltName=email:copy 301 | # An alternative to produce certificates that aren't 302 | # deprecated according to PKIX. 303 | # subjectAltName=email:move 304 | 305 | # Copy subject details 306 | # issuerAltName=issuer:copy 307 | 308 | #nsCaRevocationUrl = http://www.domain.dom/ca-crl.pem 309 | #nsBaseUrl 310 | #nsRevocationUrl 311 | #nsRenewalUrl 312 | #nsCaPolicyUrl 313 | #nsSslServerName 314 | 315 | # This really needs to be in place for it to be a proxy certificate. 316 | proxyCertInfo=critical,language:id-ppl-anyLanguage,pathlen:3,policy:foo 317 | 318 | #################################################################### 319 | [ tsa ] 320 | 321 | default_tsa = tsa_config1 # the default TSA section 322 | 323 | [ tsa_config1 ] 324 | 325 | # These are used by the TSA reply generation only. 326 | dir = ./demoCA # TSA root directory 327 | serial = $dir/tsaserial # The current serial number (mandatory) 328 | crypto_device = builtin # OpenSSL engine to use for signing 329 | signer_cert = $dir/tsacert.pem # The TSA signing certificate 330 | # (optional) 331 | certs = $dir/cacert.pem # Certificate chain to include in reply 332 | # (optional) 333 | signer_key = $dir/private/tsakey.pem # The TSA private key (optional) 334 | signer_digest = sha256 # Signing digest to use. (Optional) 335 | default_policy = tsa_policy1 # Policy if request did not specify it 336 | # (optional) 337 | other_policies = tsa_policy2, tsa_policy3 # acceptable policies (optional) 338 | digests = sha1, sha256, sha384, sha512 # Acceptable message digests (mandatory) 339 | accuracy = secs:1, millisecs:500, microsecs:100 # (optional) 340 | clock_precision_digits = 0 # number of digits after dot. (optional) 341 | ordering = yes # Is ordering defined for timestamps? 342 | # (optional, default: no) 343 | tsa_name = yes # Must the TSA name be included in the reply? 344 | # (optional, default: no) 345 | ess_cert_id_chain = no # Must the ESS cert id chain be included? 346 | # (optional, default: no) 347 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'driver' => 'single', 12 | 'path' => ygg_log_path(), 13 | ]]); 14 | } else { 15 | config(['logging.channels.ygg' => [ 16 | 'driver' => 'monolog', 17 | 'handler' => Monolog\Handler\NullHandler::class, 18 | ]]); 19 | } 20 | 21 | // 从旧版升级上来的默认继续使用旧的 UUID 生成算法 22 | if (DB::table('uuid')->count() > 0 && !Option::get('ygg_uuid_algorithm')) { 23 | Option::set('ygg_uuid_algorithm', 'v4'); 24 | } 25 | 26 | // 初次使用自动生成私钥 27 | if (option('ygg_private_key') == '') { 28 | option(['ygg_private_key' => ygg_generate_rsa_keys()['private']]); 29 | } 30 | 31 | // 记录访问详情 32 | if (request()->is('api/yggdrasil/*')) { 33 | ygg_log_http_request_and_response(); 34 | } 35 | 36 | // 保证用户修改角色名后 UUID 一致 37 | $callback = function ($model) { 38 | $new = $model->getAttribute('name'); 39 | $original = $model->getOriginal('name'); 40 | 41 | if (!$original || $original === $new) return; 42 | 43 | // 要是能执行到这里就说明新的角色名已经没人在用了 44 | // 所以残留着的 UUID 映射删掉也没问题 45 | DB::table('uuid')->where('name', $new)->delete(); 46 | DB::table('uuid')->where('name', $original)->update(['name' => $new]); 47 | }; 48 | 49 | // 仅当 UUID 生成算法为「随机生成」时保证修改角色名后 UUID 一致 50 | // 因为另一种 UUID 生成算法要最大限度兼容盗版模式,所以不做修改 51 | if (option('ygg_uuid_algorithm') == 'v4') { 52 | App\Models\Player::updating($callback); 53 | } 54 | 55 | // 向用户中心首页添加「快速配置启动器」板块 56 | if (option('ygg_show_config_section')) { 57 | $filter->add('grid:user.index', function ($grid) { 58 | $grid['widgets'][0][0][] = 'Yggdrasil::dnd'; 59 | 60 | return $grid; 61 | }); 62 | Hook::addScriptFileToPage(plugin('yggdrasil-api')->assets('dnd.js'), ['user']); 63 | } 64 | 65 | // 向管理后台菜单添加「Yggdrasil 日志」项目 66 | Hook::addMenuItem('admin', 4, [ 67 | 'title' => 'Yggdrasil::log.title', 68 | 'link' => 'admin/yggdrasil-log', 69 | 'icon' => 'fa-history' 70 | ]); 71 | 72 | // 添加 API 路由 73 | Hook::addRoute(function () { 74 | Route::namespace('Yggdrasil\Controllers') 75 | ->prefix('api/yggdrasil') 76 | ->group(function () { 77 | Route::any('', 'ConfigController@hello'); 78 | 79 | require __DIR__.'/routes.php'; 80 | }); 81 | 82 | Route::middleware(['web', 'auth', 'role:admin']) 83 | ->namespace('Yggdrasil\Controllers') 84 | ->prefix('admin') 85 | ->group(function () { 86 | Route::get('yggdrasil-log', 'ConfigController@logPage'); 87 | 88 | Route::post( 89 | 'plugins/config/yggdrasil-api/generate', 90 | 'ConfigController@generate' 91 | ); 92 | }); 93 | }); 94 | 95 | // 全局添加 ALI HTTP 响应头 96 | if (option('ygg_enable_ali')) { 97 | $kernel = app()->make(Illuminate\Contracts\Http\Kernel::class); 98 | $kernel->pushMiddleware(Yggdrasil\Middleware\AddApiIndicationHeader::class); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /callbacks.php: -------------------------------------------------------------------------------- 1 | function () { 7 | if (! Schema::hasTable('uuid')) { 8 | Schema::create('uuid', function ($table) { 9 | $table->increments('id'); 10 | $table->string('name'); 11 | $table->string('uuid', 255); 12 | }); 13 | } 14 | 15 | if (! Schema::hasTable('ygg_log')) { 16 | Schema::create('ygg_log', function ($table) { 17 | $table->increments('id'); 18 | $table->string('action'); 19 | $table->integer('user_id'); 20 | $table->integer('player_id'); 21 | $table->string('parameters')->default(''); 22 | $table->string('ip')->default(''); 23 | $table->dateTime('time'); 24 | }); 25 | } 26 | 27 | $items = [ 28 | 'ygg_uuid_algorithm' => 'v3', 29 | 'ygg_token_expire_1' => '259200', // 3 days 30 | 'ygg_token_expire_2' => '604800', // 7 days 31 | 'ygg_rate_limit' => '1000', 32 | 'ygg_skin_domain' => '', 33 | 'ygg_search_profile_max' => '5', 34 | 'ygg_private_key' => '', 35 | 'ygg_show_config_section' => 'true', 36 | 'ygg_show_activities_section' => 'true', 37 | 'ygg_enable_ali' => 'true' 38 | ]; 39 | 40 | foreach ($items as $key => $value) { 41 | if (! Option::get($key)) { 42 | Option::set($key, $value); 43 | } 44 | } 45 | 46 | $originalDefaultValue = [ 47 | 'ygg_token_expire_1' => '600', 48 | 'ygg_token_expire_2' => '1200' 49 | ]; 50 | 51 | // 原来的令牌过期时间默认值太低了,调高点 52 | foreach ($originalDefaultValue as $key => $value) { 53 | if (Option::get($key) == $value) { 54 | Option::set($key, $items[$key]); 55 | } 56 | } 57 | 58 | if (! env('YGG_VERBOSE_LOG')) { 59 | @unlink(ygg_log_path()); 60 | @unlink(storage_path('logs/yggdrasil.log')); 61 | } 62 | }, 63 | ]; 64 | -------------------------------------------------------------------------------- /lang/en/config.yml: -------------------------------------------------------------------------------- 1 | title: Yggdrasil API Configuration 2 | 3 | url: 4 | label: 'Yggdrasil API URL:' 5 | notice: Please make sure the URL above is accessible before configuring authlib-injector. 6 | 7 | common: 8 | title: Common 9 | ygg_uuid_algorithm: 10 | title: UUID Algorithm 11 | v3: 'Version 3: Keep it same with "Offline Users" (Recommended)' 12 | v4: 'Version 4: Generate randomly (DO NOT use this if you expect it can be compatible with "Offline Users")' 13 | hint: Use "Version 3" to gather compatibility with offline servers. 14 | ygg_token_expire_1: 15 | title: Token Temporarily-expired Time 16 | ygg_token_expire_2: 17 | title: Token Completely-expired Time 18 | description: Specify the expiry time of Token "Temporarily Invalid" and "Completely Invalid" in seconds. 19 | ygg_rate_limit: 20 | title: Log-in/Log-out Rate Limit 21 | hint: Time interval between two operations (ms). 22 | ygg_skin_domain: 23 | title: Additional Skin Domain Names Whitelist 24 | description: Only textures from the list here will be loaded. Split them with comma. URL of this site and current access URL are added by default. 25 | ygg_search_profile_max: 26 | title: Limit Number of Roles for Batch Query 27 | hint: How many roles can be queried in one request? 28 | ygg_show_config_section: 29 | title: Show "Quick Configuartion" 30 | label: Show "Quick Configuration" at user center. 31 | ygg_enable_ali: 32 | title: API Location Indicator 33 | label: Enable API Location Indicator (ALI). 34 | 35 | keypair: 36 | title: Keypair 37 | ygg_private_key: 38 | title: OpenSSL Private Key 39 | hint: PEM-format private key is required and public key will be generated automatically according to private key. 40 | generate: Generate Private Key 41 | submit: Submit Private Key 42 | message: Click the button below to generate a private key. 43 | valid: Valid private key. 44 | invalid: Invalid RSA private key. Please check and re-configure it. 45 | 46 | rsa: 47 | invalid: Invalid RSA private key. Please re-configure it. 48 | length: The length of RSA private key must be greater than 4096. Please re-configure it. 49 | -------------------------------------------------------------------------------- /lang/en/dnd.yml: -------------------------------------------------------------------------------- 1 | title: Quick Configuration for Launchers 2 | url: 'Our Yggdrasil API Authentication URL: ' 3 | tip: Click the button below to copy API URL or drag the button to launcher, then the authentication server will be added. 4 | button: Drag This Button to Launcher 5 | guide: Launchers Configuration Guide 6 | -------------------------------------------------------------------------------- /lang/en/exceptions.yml: -------------------------------------------------------------------------------- 1 | token: 2 | invalid: Invalid AccessToken. Please re-login. 3 | not-matched: ClientToken isn't matched with AccessToken. Please re-login. 4 | missing: No valid credential information. 5 | 6 | user: 7 | not-existed: The user that the token binds is not found. 8 | banned: You're banned. 9 | not-verified: Your email isn't verified. Please verify it before logging in. 10 | 11 | player: 12 | not-existed: Player is not found. 13 | not-matched: The player requested doesn't match with that token. 14 | owner: The player requested doesn't belong to that user. 15 | query-max: 'You can query up to :count players in one request.' 16 | 17 | auth: 18 | empty: Email address and password are required. 19 | not-existed: 'User :identification is not found.' 20 | not-matched: Invalid credential information. 21 | 22 | uuid: 'Invalid Profile UUID [:profile]. It doesn't belong to any players.' 23 | -------------------------------------------------------------------------------- /lang/en/front-end.yml: -------------------------------------------------------------------------------- 1 | key-generated: An 4096 bit OpenSSL RSA private key is generated. 2 | copied: Copied 3 | -------------------------------------------------------------------------------- /lang/en/log.yml: -------------------------------------------------------------------------------- 1 | title: Yggdrasil Logs 2 | header: 3 | action: Action 4 | params: Parameters 5 | time: Time 6 | show-details: Show Details 7 | no-records: No records. 8 | actions: 9 | authenticate: Sign In 10 | refresh: Refresh Token 11 | validate: Validate Token 12 | signout: Sign Out 13 | invalidate: Invalidate Token 14 | join: Attempt to Join Server 15 | has_joined: Join Server 16 | undefined: Unknown 17 | -------------------------------------------------------------------------------- /lang/en/middleware.yml: -------------------------------------------------------------------------------- 1 | content-type: "Content-Type" must be "application/json". 2 | throttle: 'Too frequent requests. Please try after :s seconds.' 3 | -------------------------------------------------------------------------------- /lang/zh_CN/config.yml: -------------------------------------------------------------------------------- 1 | title: Yggdrasil API 配置 2 | 3 | url: 4 | label: 本站的 Yggdrasil API 地址: 5 | notice: 请确认以上 URL 能够正常访问后再进行 authlib-injector 的配置。 6 | 7 | common: 8 | title: 常规配置 9 | ygg_uuid_algorithm: 10 | title: UUID 生成算法 11 | v3: 'Version 3: 与原盗版用户 UUID 一致(推荐)' 12 | v4: 'Version 4: 随机生成(想要同时兼容盗版登录的不要选)' 13 | hint: 选择 Version 3 以获得对原盗版服务器的最佳兼容性。 14 | ygg_token_expire_1: 15 | title: 令牌暂时失效时间 16 | ygg_token_expire_2: 17 | title: 令牌完全失效时间 18 | description: 分别指定 Token「暂时失效」与「完全失效」的过期时间,单位为秒 19 | ygg_rate_limit: 20 | title: 登录/登出频率限制 21 | hint: 两次操作之间的时间间隔(毫秒) 22 | ygg_skin_domain: 23 | title: 额外皮肤白名单域名 24 | description: 只有在此列表中的材质才能被加载。「本站地址」和「当前访问地址」已经默认添加至白名单列表,需要添加的额外白名单域名请使用半角逗号 (,) 分隔 25 | ygg_search_profile_max: 26 | title: 批量查询角色数量限制 27 | hint: 一次请求中最多能查询几个角色 28 | ygg_show_config_section: 29 | title: 显示快速配置板块 30 | label: 在用户中心首页显示「快速配置启动器」板块 31 | ygg_enable_ali: 32 | title: API 地址指示 33 | label: 开启「API 地址指示 (ALI)」功能 34 | 35 | keypair: 36 | title: 密钥对配置 37 | ygg_private_key: 38 | title: OpenSSL 私钥 39 | hint: 只需填写 PEM 格式的私钥即可,公钥会根据私钥自动生成。 40 | generate: 生成私钥 41 | submit: 保存私钥 42 | message: 使用下方的按钮来自动生成符合格式的私钥。 43 | valid: 私钥有效。 44 | invalid: 无效的私钥,请检查后重新配置。 45 | 46 | rsa: 47 | invalid: 无效的 RSA 私钥,请访问插件配置页重新设置 48 | length: RSA 私钥的长度至少为 4096,请访问插件配置页重新设置 49 | -------------------------------------------------------------------------------- /lang/zh_CN/dnd.yml: -------------------------------------------------------------------------------- 1 | title: 快速配置启动器 2 | url: 本站的 Yggdrasil API 认证服务器地址: 3 | tip: 点击下方按钮复制 API 地址,或者将按钮拖动至启动器的任意界面即可快速添加认证服务器。 4 | button: 将此按钮拖动至启动器 5 | guide: 启动器配置教程 6 | -------------------------------------------------------------------------------- /lang/zh_CN/exceptions.yml: -------------------------------------------------------------------------------- 1 | token: 2 | invalid: 无效的 AccessToken,请重新登录 3 | not-matched: 提供的 ClientToken 与 AccessToken 不匹配,请重新登录 4 | missing: 未查找到有效的登录信息,请重新登录 5 | 6 | user: 7 | not-existed: 令牌绑定的用户不存在,请重新登录 8 | banned: 你已经被本站封禁,详情请询问管理人员 9 | not-verified: 你还没有验证你的邮箱,请在通过皮肤站的邮箱验证后再尝试登录 10 | 11 | player: 12 | not-existed: 请求的角色不存在 13 | not-matched: 请求的角色与令牌绑定的角色不一致 14 | owner: 请求的角色不属于该用户 15 | query-max: '一次最多只能查询 :count 个角色' 16 | 17 | auth: 18 | empty: 邮箱或者密码没填哦 19 | not-existed: '用户 :identification 不存在' 20 | not-matched: 输入的邮箱与密码不匹配 21 | 22 | uuid: '无效的 Profile UUID [:profile],它不属于任何角色' 23 | -------------------------------------------------------------------------------- /lang/zh_CN/front-end.yml: -------------------------------------------------------------------------------- 1 | key-generated: 成功生成了一个新的 4096 bit OpenSSL RSA 私钥 2 | copied: 已复制 3 | -------------------------------------------------------------------------------- /lang/zh_CN/log.yml: -------------------------------------------------------------------------------- 1 | title: Yggdrasil 日志 2 | header: 3 | action: 动作 4 | params: 附加参数 5 | time: 时间 6 | show-details: 点击查看 7 | no-records: 暂无记录 8 | actions: 9 | authenticate: 登录 10 | refresh: 刷新令牌 11 | validate: 验证令牌 12 | signout: 登出 13 | invalidate: 吊销令牌 14 | join: 请求加入服务器 15 | has_joined: 进入服务器 16 | undefined: 未知 17 | -------------------------------------------------------------------------------- /lang/zh_CN/middleware.yml: -------------------------------------------------------------------------------- 1 | content-type: 请求的 Content-Type 必须为 application/json 2 | throttle: '请求过于频繁,请等待 :s 秒后重试' 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yggdrasil-api", 3 | "version": "4.4.0", 4 | "title": "Yggdrasil API", 5 | "description": "Yggdrasil API + authlib-injector = ✨", 6 | "author": "printempw", 7 | "url": "https://github.com/bs-community/yggdrasil-api/", 8 | "namespace": "Yggdrasil", 9 | "require": { 10 | "blessing-skin-server": "^5.0.0" 11 | }, 12 | "enchants": { 13 | "config": "Controllers\\ConfigController", 14 | "icon": { 15 | "fa": "khanda", 16 | "bg": "info" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /routes.php: -------------------------------------------------------------------------------- 1 | middleware(['Yggdrasil\Middleware\CheckContentType']) 5 | ->group(function () { 6 | // 防止暴力破解密码 7 | Route::middleware(['Yggdrasil\Middleware\Throttle']) 8 | ->group(function () { 9 | Route::post('authenticate', 'AuthController@authenticate'); 10 | Route::post('signout', 'AuthController@signout'); 11 | }); 12 | 13 | Route::post('refresh', 'AuthController@refresh'); 14 | 15 | Route::post('validate', 'AuthController@validate'); 16 | Route::post('invalidate', 'AuthController@invalidate'); 17 | }); 18 | 19 | Route::prefix('sessionserver/session/minecraft')->group(function () { 20 | Route::post('join', 'SessionController@joinServer'); 21 | Route::get('hasJoined', 'SessionController@hasJoinedServer'); 22 | 23 | Route::get('profile/{uuid}', 'ProfileController@getProfileFromUuid'); 24 | }); 25 | 26 | Route::post('api/profiles/minecraft', 'ProfileController@searchProfile'); 27 | -------------------------------------------------------------------------------- /src/Controllers/AuthController.php: -------------------------------------------------------------------------------- 1 | input('username')); 27 | Log::channel('ygg')->info("User [$identification] is try to authenticate with", [$request->except(['username', 'password'])]); 28 | $user = $this->checkUserCredentials($request); 29 | 30 | // clientToken 原样返回,如果没提供就给客户端生成一个 31 | $clientToken = $request->input('clientToken', UUID::generate()->clearDashes()); 32 | // clientToken 原样返回,生成新 accessToken 并格式化为不带符号的 UUID 33 | $accessToken = UUID::generate()->clearDashes(); 34 | 35 | // 吊销该用户的其他令牌 36 | if ($cache = Cache::get("ID_$identification")) { 37 | $expiredAccessToken = unserialize($cache)->accessToken; 38 | 39 | Cache::forget("ID_$identification"); 40 | Cache::forget("TOKEN_$expiredAccessToken"); 41 | } 42 | 43 | // 实例化并存储 Token 44 | $token = new Token($clientToken, $accessToken); 45 | $token->owner = $identification; 46 | 47 | // 准备响应 48 | $availableProfiles = $this->getAvailableProfiles($user); 49 | 50 | $result = [ 51 | 'accessToken' => $token->accessToken, 52 | 'clientToken' => $token->clientToken, 53 | 'availableProfiles' => $availableProfiles 54 | ]; 55 | 56 | if ($request->input('requestUser')) { 57 | // 用户 ID 根据其邮箱生成 58 | $result['user'] = [ 59 | 'id' => UUID::generate(5, $user->email, UUID::NS_DNS)->clearDashes(), 60 | 'properties' => [], 61 | ]; 62 | } 63 | 64 | // 当用户只有一个角色时自动帮他选择 65 | if (!empty($availableProfiles) && count($availableProfiles) === 1) { 66 | $result['selectedProfile'] = $availableProfiles[0]; 67 | $token->profileId = $availableProfiles[0]['id']; 68 | } 69 | 70 | $this->storeToken($token, $identification); 71 | Log::channel('ygg')->info("New access token [$accessToken] generated for user [$identification]"); 72 | 73 | Log::channel('ygg')->info("User [$identification] authenticated successfully", [compact('availableProfiles')]); 74 | 75 | ygg_log([ 76 | 'action' => 'authenticate', 77 | 'user_id' => $user->uid, 78 | 'parameters' => json_encode($request->except('username', 'password')) 79 | ]); 80 | 81 | return json($result); 82 | } 83 | 84 | public function refresh(Request $request) 85 | { 86 | $clientToken = $request->input('clientToken'); 87 | $accessToken = $request->input('accessToken'); 88 | 89 | Log::channel('ygg')->info("Try to refresh access token [$accessToken] with client token [$clientToken]"); 90 | 91 | $token = Token::lookup($accessToken); 92 | if (! $token) { 93 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.invalid')); 94 | } 95 | 96 | if ($clientToken && $token->clientToken !== $clientToken) { 97 | Log::info("Expect client token to be [$token->clientToken]"); 98 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.not-matched')); 99 | } 100 | 101 | $user = User::where('email', $token->owner)->first(); 102 | 103 | if (!$user) { 104 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.not-existed')); 105 | } 106 | 107 | Log::channel('ygg')->info("The given access token is owned by user [$token->owner]"); 108 | 109 | if ($user->permission == User::BANNED) { 110 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.banned')); 111 | } 112 | 113 | $availableProfiles = $this->getAvailableProfiles($user); 114 | 115 | $result = [ 116 | 'accessToken' => $token->accessToken, 117 | 'clientToken' => $token->clientToken, // 原样返回 118 | 'availableProfiles' => $availableProfiles 119 | ]; 120 | 121 | if ($request->input('requestUser')) { 122 | $result['user'] = [ 123 | 'id' => UUID::generate(5, $user->email, UUID::NS_DNS)->clearDashes(), 124 | 'properties' => [], 125 | ]; 126 | } 127 | 128 | // 当指定了 selectedProfile 时 129 | if ($selected = $request->get('selectedProfile')) { 130 | if (! Player::where('name', $selected['name'])->first()) { 131 | throw new IllegalArgumentException(trans('Yggdrasil::exceptions.player.not-existed')); 132 | } 133 | 134 | if ($token->profileId != '' && $selected != $token->profileId) { 135 | throw new IllegalArgumentException(trans('Yggdrasil::exceptions.player.not-matched')); 136 | } 137 | 138 | foreach ($availableProfiles as $profile) { 139 | if ($profile['id'] == $selected['id']) { 140 | $result['selectedProfile'] = $profile; 141 | } 142 | } 143 | 144 | if (! isset($result['selectedProfile'])) { 145 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.player.owner')); 146 | } 147 | 148 | $token->profileId = $result['selectedProfile']['id']; 149 | } else { 150 | foreach ($availableProfiles as $profile) { 151 | if ($profile['id'] == $token->profileId) { 152 | $result['selectedProfile'] = $profile; 153 | } 154 | } 155 | } 156 | 157 | // 上面那一大票检测完了,最后再刷新令牌 158 | Cache::forget("TOKEN_$accessToken"); 159 | Log::channel('ygg')->info("The old access token [$accessToken] is now revoked"); 160 | 161 | $token->accessToken = UUID::generate()->clearDashes(); 162 | $token->createdAt = time(); 163 | Log::channel('ygg')->info("New token [$token->accessToken] generated for user [$user->email]"); 164 | $this->storeToken($token, $token->owner); 165 | 166 | Log::channel('ygg')->info("Access token refreshed [$accessToken] => [$token->accessToken]"); 167 | 168 | ygg_log([ 169 | 'action' => 'refresh', 170 | 'user_id' => $user->uid, 171 | 'parameters' => json_encode($request->except('accessToken')), 172 | ]); 173 | 174 | $result['accessToken'] = $token->accessToken; 175 | return json($result); 176 | } 177 | 178 | public function validate(Request $request) 179 | { 180 | $clientToken = $request->input('clientToken'); 181 | $accessToken = $request->input('accessToken'); 182 | 183 | Log::channel('ygg')->info("Check if an access token is valid", compact('clientToken', 'accessToken')); 184 | 185 | $token = Token::lookup($accessToken); 186 | if ($token && $token->isValid()) { 187 | 188 | if ($clientToken && $clientToken !== $token->clientToken) { 189 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.not-matched')); 190 | } 191 | 192 | Log::info('Given access token is valid and matches the client token'); 193 | 194 | $user = User::where('email', $token->owner)->first(); 195 | 196 | if ($user->permission == User::BANNED) { 197 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.banned')); 198 | } 199 | 200 | ygg_log([ 201 | 'action' => 'validate', 202 | 'user_id' => $user->uid, 203 | 'parameters' => json_encode($request->except('accessToken')), 204 | ]); 205 | 206 | return response('')->setStatusCode(204); 207 | } else { 208 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.invalid')); 209 | } 210 | } 211 | 212 | public function signout(Request $request) 213 | { 214 | $identification = strtolower($request->input('username')); 215 | Log::channel('ygg')->info("User [$identification] is try to signout"); 216 | $user = $this->checkUserCredentials($request, false); 217 | 218 | // 吊销所有令牌 219 | if ($cache = Cache::get("ID_$identification")) { 220 | $accessToken = unserialize($cache)->accessToken; 221 | 222 | Cache::forget("ID_$identification"); 223 | Cache::forget("TOKEN_$accessToken"); 224 | } 225 | 226 | Log::channel('ygg')->info("User [$identification] signed out, all tokens revoked"); 227 | 228 | ygg_log([ 229 | 'action' => 'signout', 230 | 'user_id' => $user->uid, 231 | ]); 232 | 233 | return response('')->setStatusCode(204); 234 | } 235 | 236 | public function invalidate(Request $request) 237 | { 238 | $clientToken = $request->input('clientToken'); 239 | $accessToken = $request->input('accessToken'); 240 | 241 | Log::channel('ygg')->info("Try to invalidate an access token", compact('clientToken', 'accessToken')); 242 | 243 | // 据说不用检查 clientToken 与 accessToken 是否匹配 244 | if ($cache = Cache::get("TOKEN_$accessToken")) { 245 | $token = unserialize($cache); 246 | $identification = strtolower($token->owner); 247 | 248 | Cache::forget("ID_$identification"); 249 | Cache::forget("TOKEN_$accessToken"); 250 | 251 | ygg_log([ 252 | 'action' => 'invalidate', 253 | 'user_id' => User::where('email', $token->owner)->first()->uid, 254 | 'parameters' => json_encode($request->json()->all()), 255 | ]); 256 | 257 | Log::channel('ygg')->info("Access token [$accessToken] was successfully revoked"); 258 | } else { 259 | Log::channel('ygg')->error("Invalid access token [$accessToken], nothing to do"); 260 | } 261 | 262 | // 据说无论操作是否成功都应该返回 204 263 | return response('')->setStatusCode(204); 264 | } 265 | 266 | protected function checkUserCredentials(Request $request, $checkBanned = true) 267 | { 268 | $identification = $request->input('username'); 269 | $password = $request->input('password'); 270 | 271 | if (is_null($identification) || is_null($password)) { 272 | throw new IllegalArgumentException(trans('Yggdrasil::exceptions.auth.empty')); 273 | } 274 | 275 | $user = User::where('email', $identification)->first(); 276 | 277 | if (!$user) { 278 | throw new ForbiddenOperationException( 279 | trans('Yggdrasil::exceptions.auth.not-existed', compact('identification')) 280 | ); 281 | } 282 | 283 | if (!$user->verifyPassword($password)) { 284 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.auth.not-matched')); 285 | } 286 | 287 | if ($checkBanned && $user->permission == User::BANNED) { 288 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.banned')); 289 | } 290 | 291 | if (option('require_verification') && $user->verified === false) { 292 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.not-verified')); 293 | } 294 | 295 | return $user; 296 | } 297 | 298 | protected function getAvailableProfiles(User $user) 299 | { 300 | $profiles = []; 301 | 302 | foreach ($user->players as $player) { 303 | $uuid = Profile::getUuidFromName($player->name); 304 | 305 | $profiles[] = [ 306 | 'id' => $uuid, 307 | 'name' => $player->name, 308 | ]; 309 | } 310 | 311 | return $profiles; 312 | } 313 | 314 | // 推荐使用 Redis 作为缓存驱动 315 | protected function storeToken(Token $token, $identification) 316 | { 317 | $timeToFullyExpired = option('ygg_token_expire_2'); 318 | // 使用 accessToken 作为缓存主键 319 | Cache::put("TOKEN_{$token->accessToken}", serialize($token), $timeToFullyExpired); 320 | // TODO: 实现一个用户可以签发多个 Token 321 | Cache::put("ID_$identification", serialize($token), $timeToFullyExpired); 322 | 323 | Log::channel('ygg')->info("Serialized token stored to cache with expiry time $timeToFullyExpired minutes", [ 324 | 'keys' => ["TOKEN_{$token->accessToken}", "ID_$identification"], 325 | 'token' => $token, 326 | ]); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/Controllers/ConfigController.php: -------------------------------------------------------------------------------- 1 | select('ygg_uuid_algorithm', trans('Yggdrasil::config.common.ygg_uuid_algorithm.title')) 20 | ->option('v3', trans('Yggdrasil::config.common.ygg_uuid_algorithm.v3')) 21 | ->option('v4', trans('Yggdrasil::config.common.ygg_uuid_algorithm.v4')) 22 | ->hint(trans('Yggdrasil::config.common.ygg_uuid_algorithm.hint')); 23 | $form->text('ygg_token_expire_1', trans('Yggdrasil::config.common.ygg_token_expire_1.title')); 24 | $form->text('ygg_token_expire_2', trans('Yggdrasil::config.common.ygg_token_expire_2.title')) 25 | ->description(trans('Yggdrasil::config.common.ygg_token_expire_2.description')); 26 | $form->text('ygg_rate_limit', trans('Yggdrasil::config.common.ygg_rate_limit.title')) 27 | ->hint(trans('Yggdrasil::config.common.ygg_rate_limit.hint')); 28 | $form->text('ygg_skin_domain', trans('Yggdrasil::config.common.ygg_skin_domain.title')) 29 | ->description(trans('Yggdrasil::config.common.ygg_skin_domain.description')); 30 | $form->text('ygg_search_profile_max', trans('Yggdrasil::config.common.ygg_search_profile_max.title')) 31 | ->hint(trans('Yggdrasil::config.common.ygg_search_profile_max.hint')); 32 | $form->checkbox('ygg_show_config_section', trans('Yggdrasil::config.common.ygg_show_config_section.title')) 33 | ->label(trans('Yggdrasil::config.common.ygg_show_config_section.label')); 34 | $form->checkbox('ygg_enable_ali', trans('Yggdrasil::config.common.ygg_enable_ali.title')) 35 | ->label(trans('Yggdrasil::config.common.ygg_enable_ali.label')); 36 | })->handle(); 37 | 38 | $keypairForm = Option::form('keypair', trans('Yggdrasil::config.keypair.title'), function (OptionForm $form) { 39 | $form->textarea('ygg_private_key', trans('Yggdrasil::config.keypair.ygg_private_key.title')) 40 | ->rows(10) 41 | ->hint(trans('Yggdrasil::config.keypair.ygg_private_key.hint')); 42 | })->renderWithOutSubmitButton()->addButton([ 43 | 'style' => 'success', 44 | 'name' => 'generate-key', 45 | 'text' => trans('Yggdrasil::config.keypair.ygg_private_key.generate'), 46 | ])->addButton([ 47 | 'style' => 'primary', 48 | 'type' => 'submit', 49 | 'name' => 'submit-key', 50 | 'text' => trans('Yggdrasil::config.keypair.ygg_private_key.submit'), 51 | ])->addMessage(trans('Yggdrasil::config.keypair.ygg_private_key.message'))->handle(); 52 | 53 | if (openssl_pkey_get_private(option('ygg_private_key'))) { 54 | $keypairForm->addMessage(trans('Yggdrasil::config.keypair.ygg_private_key.valid'), 'success'); 55 | } else { 56 | $keypairForm->addMessage(trans('Yggdrasil::config.keypair.ygg_private_key.invalid'), 'danger'); 57 | } 58 | 59 | Hook::addScriptFileToPage(plugin('yggdrasil-api')->assets('config.js')); 60 | 61 | return view('Yggdrasil::config', [ 62 | 'forms' => ['common' => $commonForm, 'keypair' => $keypairForm], 63 | ]); 64 | } 65 | 66 | public function hello(Request $request) 67 | { 68 | // Default skin domain whitelist: 69 | // - Specified by option 'site_url' 70 | // - Extract host from current URL 71 | $extra = option('ygg_skin_domain') === '' ? [] : explode(',', option('ygg_skin_domain')); 72 | $skinDomains = array_map('trim', array_values(array_unique(array_merge($extra, [ 73 | parse_url(option('site_url'), PHP_URL_HOST), 74 | $request->getHost() 75 | ])))); 76 | 77 | $privateKey = openssl_pkey_get_private(option('ygg_private_key')); 78 | 79 | if (! $privateKey) { 80 | throw new IllegalArgumentException(trans('Yggdrasil::config.rsa.invalid')); 81 | } 82 | 83 | $keyData = openssl_pkey_get_details($privateKey); 84 | 85 | if ($keyData['bits'] < 4096) { 86 | throw new IllegalArgumentException(trans('Yggdrasil::config.rsa.length')); 87 | } 88 | 89 | $result = [ 90 | 'meta' => [ 91 | 'serverName' => option_localized('site_name'), 92 | 'implementationName' => 'Yggdrasil API for Blessing Skin', 93 | 'implementationVersion' => plugin('yggdrasil-api')->version, 94 | 'links' => [ 95 | 'homepage' => url('/') 96 | ] 97 | ], 98 | 'skinDomains' => $skinDomains, 99 | 'signaturePublickey' => $keyData['key'], 100 | ]; 101 | 102 | if (option('user_can_register')) { 103 | $result['meta']['links']['register'] = url('auth/register'); 104 | } 105 | 106 | return json($result); 107 | } 108 | 109 | public function logPage() 110 | { 111 | $logs = DB::table('ygg_log')->paginate(10); 112 | $actions = trans('Yggdrasil::log.actions'); 113 | 114 | return view('Yggdrasil::log', ['logs' => $logs, 'actions' => $actions]); 115 | } 116 | 117 | public function generate() 118 | { 119 | try { 120 | return json([ 121 | 'code' => 0, 122 | 'key' => ygg_generate_rsa_keys()['private'], 123 | ]); 124 | } catch (Exception $e) { 125 | return json('Error: '.$e->getMessage(), 1); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Controllers/ProfileController.php: -------------------------------------------------------------------------------- 1 | info("Try to get profile of player with uuid [$uuid]"); 20 | 21 | if ($profile) { 22 | Log::channel('ygg')->info("Returning profile for uuid [$uuid]", [$profile->serialize()]); 23 | return response()->json()->setContent($profile); 24 | } else { 25 | // UUID 不存在就返回 204 26 | Log::channel('ygg')->info("Profile not found for uuid [$uuid]"); 27 | return response(null)->setStatusCode(204); 28 | } 29 | } 30 | 31 | public function getProfileFromName($name) 32 | { 33 | $player = Player::where('name', $name)->first(); 34 | 35 | if (! $player) { 36 | // 角色不存在 37 | return response(null)->setStatusCode(204); 38 | } 39 | 40 | $profile = Profile::createFromPlayer($player); 41 | 42 | return response()->json()->setContent($profile); 43 | } 44 | 45 | public function searchProfile(Request $request) 46 | { 47 | $names = array_unique($request->json()->all()); 48 | 49 | Log::channel('ygg')->info('Search profiles by player names as listed', array_values($names)); 50 | 51 | if (count($names) > option('ygg_search_profile_max')) { 52 | throw new ForbiddenOperationException( 53 | trans('Yggdrasil::exceptions.player.query-max', ['count' => option('ygg_search_profile_max')]) 54 | ); 55 | } 56 | 57 | $profiles = []; 58 | 59 | foreach ($names as $name) { 60 | $player = Player::where('name', $name)->first(); 61 | 62 | if ($player) { 63 | $profile = Profile::createFromPlayer($player); 64 | 65 | $profiles[] = [ 66 | 'id' => $profile->uuid, 67 | 'name' => $name 68 | ]; 69 | } 70 | } 71 | 72 | return json($profiles); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Controllers/SessionController.php: -------------------------------------------------------------------------------- 1 | input('accessToken'); 23 | $selectedProfile = $request->input('selectedProfile'); 24 | $serverId = $request->input('serverId'); 25 | 26 | Log::channel('ygg')->info("Player [$selectedProfile] is trying to join server [$serverId] with access token [$accessToken]"); 27 | 28 | $result = DB::table('uuid')->where('uuid', $selectedProfile)->first(); 29 | 30 | if (! $result) { 31 | // 据说 Mojang 在这种情况下是会返回 403 的 32 | throw new ForbiddenOperationException( 33 | trans('Yggdrasil::exceptions.uuid', ['profile' => $selectedProfile]) 34 | ); 35 | } 36 | 37 | $player = Player::where('name', $result->name)->first(); 38 | 39 | if (! $player) { 40 | // 删除已失效的 UUID 映射(e.g. 其对应的角色已被删除) 41 | DB::table('uuid')->where('uuid', $selectedProfile)->delete(); 42 | 43 | throw new ForbiddenOperationException( 44 | trans('Yggdrasil::exceptions.uuid', ['profile' => $selectedProfile]) 45 | ); 46 | } 47 | 48 | $identification = strtolower($player->user->email); 49 | 50 | Log::channel('ygg')->info("Player [$selectedProfile]'s name is [$player->name], belongs to user [$identification]"); 51 | 52 | $token = Token::lookup($accessToken); 53 | if ($token && $token->isValid()) { 54 | 55 | Log::channel('ygg')->info("All access tokens issued for user [$identification] are as listed", [$token]); 56 | 57 | if ($token->accessToken != $accessToken) { 58 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.invalid')); 59 | } 60 | 61 | if ($token->profileId != $selectedProfile) { 62 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.player.not-matched')); 63 | } 64 | 65 | if ($player->user->permission == User::BANNED) { 66 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.banned')); 67 | } 68 | 69 | // 加入服务器 70 | Cache::forever("SERVER_$serverId", $selectedProfile); 71 | } elseif ($this->mojangVerified($player) && $this->validateMojang($accessToken)) { 72 | if ($player->user->permission == User::BANNED) { 73 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.user.banned')); 74 | } 75 | 76 | Log::channel('ygg')->info("Player [$player->name] is joining server with Mojang verified account."); 77 | // 加入服务器 78 | Cache::forever("SERVER_$serverId", $selectedProfile); 79 | } else { 80 | // 指定角色所属的用户没有签发任何令牌 81 | throw new ForbiddenOperationException(trans('Yggdrasil::exceptions.token.missing')); 82 | } 83 | 84 | Log::channel('ygg')->info("Player [$selectedProfile] successfully joined the server [$serverId]"); 85 | 86 | ygg_log([ 87 | 'action' => 'join', 88 | 'user_id' => $player->uid, 89 | 'player_id' => $player->pid, 90 | 'parameters' => json_encode($request->except('accessToken')), 91 | ]); 92 | 93 | return response('')->setStatusCode(204); 94 | } 95 | 96 | public function hasJoinedServer(Request $request) 97 | { 98 | $name = $request->input('username'); 99 | $serverId = $request->input('serverId'); 100 | $ip = $request->input('ip'); 101 | 102 | Log::channel('ygg')->info("Checking if player [$name] has joined the server [$serverId] with IP [$ip]"); 103 | 104 | // 检查是否进行过 join 请求 105 | if ($selectedProfile = Cache::get("SERVER_$serverId")) { 106 | $profile = Profile::createFromUuid($selectedProfile); 107 | 108 | // TODO: 检查 IP 地址 109 | if ($name === $profile->name) { 110 | // 检查完成后马上删除缓存键值对 111 | Cache::forget("SERVER_$serverId"); 112 | Log::channel('ygg')->info("Player [$name] was in the server [$serverId]"); 113 | 114 | // 这里返回的 Profile 必须带材质的数据签名 115 | $response = $profile->serialize(false); 116 | Log::channel('ygg')->info("Returning player [$name]'s profile", [$response]); 117 | 118 | ygg_log(array_merge([ 119 | 'action' => 'has_joined', 120 | 'user_id' => $profile->player->uid, 121 | 'player_id' => $profile->player->pid, 122 | 'parameters' => json_encode($request->except('username')), 123 | ], ($ip ? compact('ip') : []))); 124 | 125 | return response()->json()->setContent($response); 126 | } 127 | } 128 | 129 | Log::channel('ygg')->info("Player [$name] was not in the server [$serverId]"); 130 | return response('')->setStatusCode(204); 131 | } 132 | 133 | protected function mojangVerified($player) 134 | { 135 | if (! Schema::hasTable('mojang_verifications')) { 136 | return false; 137 | } 138 | 139 | return DB::table('mojang_verifications')->where('user_id', $player->uid)->exists(); 140 | } 141 | 142 | protected function validateMojang($accessToken) 143 | { 144 | try { 145 | $response = Http::post('https://authserver.mojang.com/validate', [ 146 | 'json' => ['accessToken' => $accessToken], 147 | ]); 148 | return $response->status() === 204; 149 | } catch (\Exception $e) { 150 | return false; 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Exceptions/ForbiddenOperationException.php: -------------------------------------------------------------------------------- 1 | statusCode); 39 | 40 | $this->cause = $cause; 41 | $this->errorMessage = $message; 42 | 43 | Log::channel('ygg')->info(sprintf('%s %s %s', $_SERVER['SERVER_PROTOCOL'], $this->statusCode, $this->error), compact('message', 'cause')); 44 | 45 | $this->render()->send(); 46 | exit; 47 | } 48 | 49 | protected function render() 50 | { 51 | $result = [ 52 | 'error' => $this->error, 53 | 'errorMessage' => $this->errorMessage 54 | ]; 55 | 56 | if ($this->cause !== "") { 57 | $result['cause'] = $this->cause; 58 | } 59 | 60 | return json($result)->setStatusCode($this->statusCode); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Middleware/AddApiIndicationHeader.php: -------------------------------------------------------------------------------- 1 | headers->set( 16 | 'X-Authlib-Injector-API-Location', 17 | url('api/yggdrasil') 18 | ); 19 | } 20 | 21 | return $response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Middleware/CheckContentType.php: -------------------------------------------------------------------------------- 1 | isJson()) { 10 | return json([ 11 | 'error' => 'Unsupported Media Type', 12 | 'errorMessage' => trans('Yggdrasil::middleware.content-type'), 13 | ]); 14 | } 15 | 16 | return $next($request); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Middleware/Throttle.php: -------------------------------------------------------------------------------- 1 | input('username')); 14 | $currentTimeInMillisecond = microtime(true) * 1000; 15 | $retryAfter = option('ygg_rate_limit') - ($currentTimeInMillisecond - Cache::get($id)); 16 | 17 | if ($retryAfter > 0) { 18 | throw new ForbiddenOperationException( 19 | trans('Yggdrasil::middleware.throttle', ['s' => ceil($retryAfter / 1000)]) 20 | ); 21 | } 22 | 23 | Cache::put($id, $currentTimeInMillisecond, 3600); 24 | 25 | return $next($request); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Models/Profile.php: -------------------------------------------------------------------------------- 1 | round(microtime(true) * 1000), 41 | 'profileId' => UUID::format($this->uuid), 42 | 'profileName' => $this->name, 43 | 'isPublic' => true, 44 | 'textures' => [], 45 | ]; 46 | 47 | // 检查 RSA 私钥 48 | if ($unsigned === false) { 49 | $key = openssl_pkey_get_private(option('ygg_private_key')); 50 | 51 | if (! $key) { 52 | throw new IllegalArgumentException( 53 | trans('Yggdrasil::config.rsa.invalid') 54 | ); 55 | } 56 | 57 | $textures['signatureRequired'] = true; 58 | } 59 | 60 | // 避免 BungeeCord 服务器上可能出现无法加载材质的 Bug 61 | app('url')->forceRootUrl(option('site_url')); 62 | 63 | if ($this->skin != "") { 64 | $textures['textures']['SKIN'] = [ 65 | 'url' => url("textures/{$this->skin}"), 66 | ]; 67 | 68 | if ($this->model == "slim") { 69 | $textures['textures']['SKIN']['metadata'] = ['model' => 'slim']; 70 | } 71 | } elseif ( 72 | Schema::hasTable('mojang_verifications') && 73 | DB::table('mojang_verifications')->where('uuid', $this->uuid)->exists() 74 | ) { 75 | // 如果该角色没有在皮肤站设置皮肤,就从 Mojang 获取。 76 | $skin = $this->fetchProfileFromMojang('SKIN'); 77 | if ($skin) { 78 | $textures['textures']['SKIN'] = $skin; 79 | } 80 | } 81 | 82 | if ($this->cape != "") { 83 | $textures['textures']['CAPE'] = [ 84 | 'url' => url("textures/{$this->cape}") 85 | ]; 86 | } elseif ( 87 | Schema::hasTable('mojang_verifications') && 88 | DB::table('mojang_verifications')->where('uuid', $this->uuid)->exists() 89 | ) { 90 | // 如果该角色没有在皮肤站设置披风,就从 Mojang 获取。 91 | $cape = $this->fetchProfileFromMojang('CAPE'); 92 | if ($cape) { 93 | $textures['textures']['CAPE'] = $cape; 94 | } 95 | } 96 | 97 | $result = [ 98 | 'id' => UUID::format($this->uuid), 99 | 'name' => $this->name, 100 | 'properties' => [ 101 | [ 102 | 'name' => 'textures', 103 | 'value' => base64_encode( 104 | json_encode($textures, JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT) 105 | ), 106 | ], 107 | ], 108 | ]; 109 | 110 | if ($unsigned === false) { 111 | // 给每个 properties 签名 112 | foreach ($result['properties'] as &$prop) { 113 | $signature = $this->sign($prop['value'], $key); 114 | 115 | $prop['signature'] = base64_encode($signature); 116 | } 117 | 118 | unset($prop); 119 | openssl_free_key($key); 120 | } 121 | 122 | return json_encode($result, JSON_UNESCAPED_SLASHES); 123 | } 124 | 125 | public function __toString() 126 | { 127 | return $this->serialize(); 128 | } 129 | 130 | public static function getUuidFromName($name) 131 | { 132 | $result = DB::table('uuid')->where('name', $name)->first(); 133 | 134 | if (! $result) { 135 | // 分配新的 UUID 136 | $result = UUID::generateMinecraftUuid($name)->clearDashes(); 137 | DB::table('uuid')->insert(['name' => $name, 'uuid' => $result]); 138 | 139 | Log::channel('ygg')->info("New uuid [$result] allocated to player [$name]"); 140 | } else { 141 | $result = $result->uuid; 142 | } 143 | 144 | return $result; 145 | } 146 | 147 | public static function createFromUuid($uuid) 148 | { 149 | $result = DB::table('uuid')->where('uuid', $uuid)->first(); 150 | 151 | if ($result && ($player = Player::where('name', $result->name)->first())) { 152 | return static::createFromPlayer($player); 153 | } 154 | } 155 | 156 | public static function createFromPlayer(Player $player) 157 | { 158 | $profile = new static(); 159 | $model = 'default'; 160 | if ($t = Texture::find($player->tid_skin)) { 161 | $model = $t->type == 'steve' ? 'default' : 'slim'; 162 | } 163 | 164 | $profile->uuid = static::getUuidFromName($player->name); 165 | $profile->name = $player->name; 166 | $profile->model = $model; 167 | $profile->player = $player; 168 | $profile->skin = optional($player->skin)->hash; 169 | $profile->cape = optional($player->cape)->hash; 170 | 171 | return $profile; 172 | } 173 | 174 | protected function fetchProfileFromMojang($type) 175 | { 176 | $type = strtoupper($type); 177 | $profile = Cache::get('mojang_profile_'.$this->uuid, function () { 178 | try { 179 | $response = Http::get('https://sessionserver.mojang.com/session/minecraft/profile/'.$this->uuid); 180 | if ($response->ok()) { 181 | $body = $response->json(); 182 | Cache::put('mojang_profile_'.$this->uuid, $body, 300); 183 | 184 | return $body; 185 | } else { 186 | return null; 187 | } 188 | } catch (\Exception $e) { 189 | return null; 190 | } 191 | }); 192 | 193 | if (! $profile) { 194 | return null; 195 | } 196 | $property = Arr::first($profile['properties'], function ($item) { 197 | return $item['name'] === 'textures'; 198 | }); 199 | if (! $property) { 200 | return null; 201 | } 202 | return Arr::get(json_decode(base64_decode($property['value']), true)['textures'], $type); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Models/Token.php: -------------------------------------------------------------------------------- 1 | clientToken = $clientToken; 18 | $this->accessToken = $accessToken; 19 | $this->createdAt = time(); 20 | } 21 | 22 | public function isValid() 23 | { 24 | return (time() - $this->createdAt - option('ygg_token_expire_1')) < 0; 25 | } 26 | 27 | public function isRefreshable() 28 | { 29 | return (time() - $this->createdAt - option('ygg_token_expire_2')) < 0; 30 | } 31 | 32 | // 这个方法只是为了方便写日志 33 | public function serialize() 34 | { 35 | return [ 36 | 'clientToken' => $this->clientToken, 37 | 'accessToken' => $this->accessToken, 38 | 'owner' => $this->owner, 39 | 'createdAt' => $this->createdAt 40 | ]; 41 | } 42 | 43 | /** 44 | * Lookup the specified token, or null if the token does not exist or has expired. 45 | * The returned token is guaranteed to be refreshable, but it may not be valid. 46 | */ 47 | public static function lookup(string $accessToken) 48 | { 49 | $cache = Cache::get("TOKEN_$accessToken"); 50 | if ($cache) { 51 | $token = unserialize($cache); 52 | if ($token->isRefreshable()) { 53 | return $token; 54 | } else { 55 | Cache::forget("TOKEN_$accessToken"); 56 | } 57 | } 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Utils/UUID.php: -------------------------------------------------------------------------------- 1 | bytes = $uuid; 126 | 127 | // Optimize the most common use 128 | $this->string = bin2hex(substr($uuid, 0, 4)) . "-" . 129 | bin2hex(substr($uuid, 4, 2)) . "-" . 130 | bin2hex(substr($uuid, 6, 2)) . "-" . 131 | bin2hex(substr($uuid, 8, 2)) . "-" . 132 | bin2hex(substr($uuid, 10, 6)); 133 | } 134 | 135 | 136 | /** 137 | * @param int $ver 138 | * @param string $node 139 | * @param string $ns 140 | * @return Uuid 141 | * @throws Exception 142 | */ 143 | public static function generate($ver = 4, $node = null, $ns = null) 144 | { 145 | /* Create a new UUID based on provided data. */ 146 | switch ((int)$ver) { 147 | case 1: 148 | return new static(static::mintTime($node)); 149 | case 2: 150 | // Version 2 is not supported 151 | throw new Exception('Version 2 is unsupported.'); 152 | case 3: 153 | return new static(static::mintName(static::MD5, $node, $ns)); 154 | case 4: 155 | return new static(static::mintRand()); 156 | case 5: 157 | return new static(static::mintName(static::SHA1, $node, $ns)); 158 | default: 159 | throw new Exception('Selected version is invalid or unsupported.'); 160 | } 161 | } 162 | 163 | /** 164 | * Generate a UUID with the algorithm specified by the `ygg_uuid_algorithm` option. 165 | * 166 | * @param string $playerName 167 | * @return Uuid 168 | */ 169 | public static function generateMinecraftUuid($playerName) 170 | { 171 | if (option('ygg_uuid_algorithm') == 'v3') { 172 | // @see https://gist.github.com/games647/2b6a00a8fc21fd3b88375f03c9e2e603 173 | $data = hex2bin(md5("OfflinePlayer:" . $playerName)); 174 | $data[6] = chr(ord($data[6]) & 0x0f | 0x30); 175 | $data[8] = chr(ord($data[8]) & 0x3f | 0x80); 176 | 177 | return static::import(bin2hex($data)); 178 | } else if (option('ygg_uuid_algorithm') == 'v4') { 179 | return static::generate(4); 180 | } 181 | 182 | throw new Exception('The value of ygg_uuid_algorithm option is invalid.'); 183 | } 184 | 185 | /** 186 | * Generates a Version 1 UUID. 187 | * These are derived from the time at which they were generated. 188 | * 189 | * @param string $node 190 | * @return string 191 | */ 192 | protected static function mintTime($node = null) 193 | { 194 | 195 | /** Get time since Gregorian calendar reform in 100ns intervals 196 | * This is exceedingly difficult because of PHP's (and pack()'s) 197 | * integer size limits. 198 | * Note that this will never be more accurate than to the microsecond. 199 | */ 200 | $time = microtime(1) * 10000000 + static::INTERVAL; 201 | 202 | // Convert to a string representation 203 | $time = sprintf("%F", $time); 204 | 205 | //strip decimal point 206 | preg_match("/^\d+/", $time, $time); 207 | 208 | // And now to a 64-bit binary representation 209 | $time = base_convert($time[0], 10, 16); 210 | $time = pack("H*", str_pad($time, 16, "0", STR_PAD_LEFT)); 211 | 212 | // Reorder bytes to their proper locations in the UUID 213 | $uuid = $time[4] . $time[5] . $time[6] . $time[7] . $time[2] . $time[3] . $time[0] . $time[1]; 214 | 215 | // Generate a random clock sequence 216 | $uuid .= static::randomBytes(2); 217 | 218 | // set variant 219 | $uuid[8] = chr(ord($uuid[8]) & static::CLEAR_VAR | static::VAR_RFC); 220 | 221 | // set version 222 | $uuid[6] = chr(ord($uuid[6]) & static::CLEAR_VER | static::VERSION_1); 223 | 224 | // Set the final 'node' parameter, a MAC address 225 | if (!is_null($node)) { 226 | $node = static::makeBin($node, 6); 227 | } 228 | 229 | // If no node was provided or if the node was invalid, 230 | // generate a random MAC address and set the multicast bit 231 | if (is_null($node)) { 232 | $node = static::randomBytes(6); 233 | $node[0] = pack("C", ord($node[0]) | 1); 234 | } 235 | 236 | $uuid .= $node; 237 | 238 | return $uuid; 239 | } 240 | 241 | /** 242 | * Randomness is returned as a string of bytes 243 | * 244 | * @param $bytes 245 | * @return string 246 | */ 247 | public static function randomBytes($bytes) 248 | { 249 | return call_user_func(array('static', static::initRandom()), $bytes); 250 | } 251 | 252 | /** 253 | * Trying for php 7 secure random generator, falling back to openSSL and Mcrypt. 254 | * If none of the above is found, falls back to mt_rand 255 | * Since laravel 4.* and 5.0 requires Mcrypt and 5.1 requires OpenSSL the fallback should never be used. 256 | * 257 | * @throws Exception 258 | * @return string 259 | */ 260 | public static function initRandom() 261 | { 262 | if (function_exists('random_bytes')) { 263 | return 'randomPhp7'; 264 | } elseif (function_exists('openssl_random_pseudo_bytes')) { 265 | return 'randomOpenSSL'; 266 | } elseif (function_exists('mcrypt_encrypt')) { 267 | return 'randomMcrypt'; 268 | } 269 | 270 | // This is not the best randomizer (using mt_rand)... 271 | return 'randomTwister'; 272 | } 273 | 274 | /** 275 | * Insure that an input string is either binary or hexadecimal. 276 | * Returns binary representation, or false on failure. 277 | * 278 | * @param string $str 279 | * @param integer $len 280 | * @return string|null 281 | */ 282 | protected static function makeBin($str, $len) 283 | { 284 | if ($str instanceof self) { 285 | return $str->bytes; 286 | } 287 | if (strlen($str) === $len) { 288 | return $str; 289 | } else { 290 | // strip URN scheme and namespace 291 | $str = preg_replace('/^urn:uuid:/is', '', $str); 292 | } 293 | // strip non-hex characters 294 | $str = preg_replace('/[^a-f0-9]/is', '', $str); 295 | if (strlen($str) !== ($len * 2)) { 296 | return null; 297 | } else { 298 | return pack("H*", $str); 299 | } 300 | } 301 | 302 | /** 303 | * Generates a Version 3 or Version 5 UUID. 304 | * These are derived from a hash of a name and its namespace, in binary form. 305 | * 306 | * @param string $ver 307 | * @param string $node 308 | * @param string $ns 309 | * @return string 310 | * @throws Exception 311 | */ 312 | protected static function mintName($ver, $node, $ns) 313 | { 314 | if (empty($node)) { 315 | throw new Exception('A name-string is required for Version 3 or 5 UUIDs.'); 316 | } 317 | 318 | // if the namespace UUID isn't binary, make it so 319 | $ns = static::makeBin($ns, 16); 320 | if (is_null($ns)) { 321 | throw new Exception('A binary namespace is required for Version 3 or 5 UUIDs.'); 322 | } 323 | 324 | $version = null; 325 | $uuid = null; 326 | 327 | switch ($ver) { 328 | case static::MD5: 329 | $version = static::VERSION_3; 330 | $uuid = md5($ns . $node, 1); 331 | break; 332 | case static::SHA1: 333 | $version = static::VERSION_5; 334 | $uuid = substr(sha1($ns . $node, 1), 0, 16); 335 | break; 336 | default: 337 | // no default really required here 338 | } 339 | 340 | // set variant 341 | $uuid[8] = chr(ord($uuid[8]) & static::CLEAR_VAR | static::VAR_RFC); 342 | 343 | // set version 344 | $uuid[6] = chr(ord($uuid[6]) & static::CLEAR_VER | $version); 345 | 346 | return ($uuid); 347 | } 348 | 349 | /** 350 | * Generate a Version 4 UUID. 351 | * These are derived solely from random numbers. 352 | * generate random fields 353 | * 354 | * @return string 355 | */ 356 | protected static function mintRand() 357 | { 358 | $uuid = static::randomBytes(16); 359 | // set variant 360 | $uuid[8] = chr(ord($uuid[8]) & static::CLEAR_VAR | static::VAR_RFC); 361 | // set version 362 | $uuid[6] = chr(ord($uuid[6]) & static::CLEAR_VER | static::VERSION_4); 363 | 364 | return $uuid; 365 | } 366 | 367 | /** 368 | * Import an existing UUID 369 | * 370 | * @param string $uuid 371 | * @return Uuid 372 | */ 373 | public static function import($uuid) 374 | { 375 | return new static(static::makeBin($uuid, 16)); 376 | } 377 | 378 | /** 379 | * Compares the binary representations of two UUIDs. 380 | * The comparison will return true if they are bit-exact, 381 | * or if neither is valid. 382 | * 383 | * @param string $a 384 | * @param string $b 385 | * @return string|string 386 | */ 387 | public static function compare($a, $b) 388 | { 389 | if (static::makeBin($a, 16) == static::makeBin($b, 16)) { 390 | return true; 391 | } else { 392 | return false; 393 | } 394 | } 395 | 396 | /** 397 | * Get the specified number of random bytes, using random_bytes(). 398 | * Randomness is returned as a string of bytes 399 | * 400 | * Requires Php 7, or random_compact polyfill 401 | * 402 | * @param $bytes 403 | * @return mixed 404 | */ 405 | protected static function randomPhp7($bytes) { 406 | return random_bytes($bytes); 407 | } 408 | 409 | /** 410 | * Get the specified number of random bytes, using openssl_random_pseudo_bytes(). 411 | * Randomness is returned as a string of bytes. 412 | * 413 | * @param $bytes 414 | * @return mixed 415 | */ 416 | protected static function randomOpenSSL($bytes) 417 | { 418 | return openssl_random_pseudo_bytes($bytes); 419 | } 420 | 421 | /** 422 | * Get the specified number of random bytes, using mcrypt_create_iv(). 423 | * Randomness is returned as a string of bytes. 424 | * 425 | * @param $bytes 426 | * @return string 427 | */ 428 | protected static function randomMcrypt($bytes) 429 | { 430 | return mcrypt_create_iv($bytes, MCRYPT_DEV_URANDOM); 431 | } 432 | 433 | /** 434 | * Get the specified number of random bytes, using mt_rand(). 435 | * Randomness is returned as a string of bytes. 436 | * 437 | * @param integer $bytes 438 | * @return string 439 | */ 440 | protected static function randomTwister($bytes) 441 | { 442 | $rand = ""; 443 | for ($a = 0; $a < $bytes; $a++) { 444 | $rand .= chr(mt_rand(0, 255)); 445 | } 446 | 447 | return $rand; 448 | } 449 | 450 | /** 451 | * Get the UUID without dashes separated. 452 | * 453 | * @return string 454 | */ 455 | public function clearDashes() 456 | { 457 | return str_replace('-', '', $this->string); 458 | } 459 | 460 | public static function format($uuid) 461 | { 462 | return str_replace('-', '', $uuid); 463 | } 464 | 465 | /** 466 | * @param string $var 467 | * @return string|string|number|number|number|number|number|NULL|number|NULL|NULL 468 | */ 469 | public function __get($var) 470 | { 471 | switch ($var) { 472 | case "bytes": 473 | return $this->bytes; 474 | // no break 475 | case "hex": 476 | return bin2hex($this->bytes); 477 | // no break 478 | case "string": 479 | return $this->__toString(); 480 | // no break 481 | case "urn": 482 | return "urn:uuid:" . $this->__toString(); 483 | // no break 484 | case "version": 485 | return ord($this->bytes[6]) >> 4; 486 | // no break 487 | case "variant": 488 | $byte = ord($this->bytes[8]); 489 | if ($byte >= static::VAR_RES) { 490 | return 3; 491 | } elseif ($byte >= static::VAR_MS) { 492 | return 2; 493 | } elseif ($byte >= static::VAR_RFC) { 494 | return 1; 495 | } else { 496 | return 0; 497 | } 498 | // no break 499 | case "node": 500 | if (ord($this->bytes[6]) >> 4 == 1) { 501 | return bin2hex(substr($this->bytes, 10)); 502 | } else { 503 | return null; 504 | } 505 | // no break 506 | case "time": 507 | if (ord($this->bytes[6]) >> 4 == 1) { 508 | // Restore contiguous big-endian byte order 509 | $time = bin2hex($this->bytes[6] . $this->bytes[7] . $this->bytes[4] . $this->bytes[5] . 510 | $this->bytes[0] . $this->bytes[1] . $this->bytes[2] . $this->bytes[3]); 511 | // Clear version flag 512 | $time[0] = "0"; 513 | 514 | // Do some reverse arithmetic to get a Unix timestamp 515 | return (hexdec($time) - static::INTERVAL) / 10000000; 516 | } else { 517 | return null; 518 | } 519 | // no break 520 | default: 521 | return null; 522 | // no break 523 | } 524 | } 525 | 526 | /** 527 | * Return the UUID 528 | * 529 | * @return string 530 | */ 531 | public function __toString() 532 | { 533 | return $this->string; 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /src/Utils/helpers.php: -------------------------------------------------------------------------------- 1 | 4096, 23 | 'private_key_type' => OPENSSL_KEYTYPE_RSA, 24 | 'config' => plugin('yggdrasil-api')->getPath().'/assets/openssl.cnf' 25 | ]); 26 | 27 | $res = openssl_pkey_new($config); 28 | 29 | if (! $res) { 30 | throw new Exception(openssl_error_string(), 1); 31 | } 32 | 33 | openssl_pkey_export($res, $privateKey, null, $config); 34 | 35 | return [ 36 | 'private' => $privateKey, 37 | 'public' => openssl_pkey_get_details($res)['key'] 38 | ]; 39 | } 40 | } 41 | 42 | if (! function_exists('ygg_log_http_request_and_response')) { 43 | 44 | function ygg_log_http_request_and_response() 45 | { 46 | Log::channel('ygg')->info('============================================================'); 47 | Log::channel('ygg')->info(request()->method(), [request()->path()]); 48 | 49 | Event::listen('kernel.handled', function ($request, $response) { 50 | $statusCode = $response->getStatusCode(); 51 | $statusText = Symfony\Component\HttpFoundation\Response::$statusTexts[$statusCode]; 52 | Log::channel('ygg')->info(sprintf('HTTP/%s %s %s', $response->getProtocolVersion(), $statusCode, $statusText)); 53 | }); 54 | } 55 | } 56 | 57 | if (! function_exists('ygg_log')) { 58 | 59 | function ygg_log($params) 60 | { 61 | if (env('YGG_VERBOSE_LOG')) { 62 | $data = array_merge([ 63 | 'action' => 'undefined', 64 | 'user_id' => 0, 65 | 'player_id' => 0, 66 | 'parameters' => '[]', 67 | 'ip' => (new Whip())->getValidIpAddress(), 68 | 'time' => Carbon::now(), 69 | ], $params); 70 | 71 | return DB::table('ygg_log')->insert($data); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /views/config.twig: -------------------------------------------------------------------------------- 1 | {% extends 'admin.base' %} 2 | 3 | {% block title %}{{ trans('Yggdrasil::config.title') }}{% endblock %} 4 | 5 | {% block content %} 6 | 11 | 12 |
13 |
14 | {{ forms.common|raw }} 15 |
16 |
17 |
18 |
19 |

API Root

20 |
21 |
22 |

{{ trans('Yggdrasil::config.url.label') }}{{ url('api/yggdrasil') }}

23 |

{{ trans('Yggdrasil::config.url.notice') }}

24 |
25 |
26 | 27 | {{ forms.keypair|raw }} 28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /views/dnd.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ trans('Yggdrasil::dnd.title') }}

4 |
5 |
6 |

{{ trans('Yggdrasil::dnd.url') }}{{ url('/api/yggdrasil') }}

7 |

{{ trans('Yggdrasil::dnd.tip') }}

8 |
9 | 26 |
27 | -------------------------------------------------------------------------------- /views/log.twig: -------------------------------------------------------------------------------- 1 | {% extends 'admin.base' %} 2 | 3 | {% block title %}{{ trans('Yggdrasil::log.title') }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for log in logs %} 22 | 23 | 24 | 25 | 26 | 27 | 35 | 36 | 37 | 38 | {% else %} 39 | 40 | 41 | 42 | {% endfor %} 43 | 44 |
#{{ trans('Yggdrasil::log.header.action') }}UIDPID{{ trans('Yggdrasil::log.header.params') }}IP{{ trans('Yggdrasil::log.header.time') }}
{{ log.id }}{{ actions[log.action] }}{{ log.user_id }}{{ log.player_id }} 28 | 32 | {{ trans('Yggdrasil::log.show-details') }} 33 | 34 | {{ log.ip }}{{ log.time }}
{{ trans('Yggdrasil::log.no-records') }}
45 |
46 | 51 |
52 | {% endblock %} 53 | --------------------------------------------------------------------------------