🎉 完成基本的演示和样例

This commit is contained in:
loveuer 2024-10-23 17:46:15 +08:00
commit aefc004e33
56 changed files with 2648 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.idea
.vscode
.DS_Store
xtest
*.db
*.sqlite
*.sqlite3

54
go.mod Normal file
View File

@ -0,0 +1,54 @@
module uauth
go 1.20
require (
gitea.com/taozitaozi/gredis v0.0.0-20240131032054-b02845ce1e9d
github.com/glebarez/sqlite v1.11.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jedib0t/go-pretty/v6 v6.6.1
github.com/loveuer/nf v0.2.12
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.26.0
golang.org/x/oauth2 v0.23.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/onsi/gomega v1.27.6 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sirupsen/logrus v1.9.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

115
go.sum Normal file
View File

@ -0,0 +1,115 @@
gitea.com/taozitaozi/gredis v0.0.0-20240131032054-b02845ce1e9d h1:TpEOdRGqwzxx+DaN18nFE+g4EQYjneZOO1jcHtSon/g=
gitea.com/taozitaozi/gredis v0.0.0-20240131032054-b02845ce1e9d/go.mod h1:QtcL846XUtSnhmW6TZAujUQ9V5jalY7frxzZOs00kFI=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc=
github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/loveuer/nf v0.2.12 h1:1Og+ORHsOWKFmy9kKJhjvXDkdbaurH82HjIxuGA3nNM=
github.com/loveuer/nf v0.2.12/go.mod h1:M6reF17/kJBis30H4DxR5hrtgo/oJL4AV4cBe4HzJLw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

0
httptest/uauth/127.0.0.1 Normal file
View File

View File

@ -0,0 +1,18 @@
meta {
name: auth req
type: http
seq: 2
}
get {
url: http://127.0.0.1:8080/authorize?client_id=12345&response_type=code&redirect_uri=http://localhost:8080/callback&scopde=balaba
body: none
auth: none
}
params:query {
client_id: 12345
response_type: code
redirect_uri: http://localhost:8080/callback
scopde: balaba
}

View File

@ -0,0 +1,9 @@
{
"version": "1",
"name": "uauth",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

89
internal/client/client.go Normal file
View File

@ -0,0 +1,89 @@
package client
import (
"context"
_ "embed"
"encoding/json"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"golang.org/x/oauth2"
"net/http"
"uauth/internal/tool"
)
//go:embed login.html
var page string
var (
config = oauth2.Config{
ClientID: "test",
ClientSecret: "Foobar123",
Endpoint: oauth2.Endpoint{
AuthURL: "http://localhost:8080/oauth/v2/authorize",
TokenURL: "http://localhost:8080/oauth/v2/token",
},
RedirectURL: "http://localhost:18080/oauth/v2/redirect",
Scopes: []string{"test"},
}
state = uuid.New().String()[:8]
)
func Run(ctx context.Context) error {
app := nf.New()
app.Get("/login", handleLogin)
app.Get("/oauth/v2/redirect", handleRedirect)
go func() {
<-ctx.Done()
_ = app.Shutdown(tool.Timeout(2))
}()
return app.Run(":18080")
}
func handleLogin(c *nf.Ctx) error {
if c.Query("oauth") != "" {
uri := config.AuthCodeURL(state)
log.Info("[C] oauth config client_secret = %s", config.ClientSecret)
log.Info("[C] redirect to oauth2 server uri = %s", uri)
return c.Redirect(uri, http.StatusFound)
}
return c.HTML(page)
}
func handleRedirect(c *nf.Ctx) error {
type Req struct {
State string `query:"state"`
Code string `query:"code"`
Scope string `query:"scope"`
ClientId string `query:"client_id"`
}
var (
err error
req = new(Req)
token *oauth2.Token
)
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.State != state {
log.Error("[C] state mismatch, want = %s, got = %s", state, req.State)
return c.Status(http.StatusBadRequest).SendString("Bad Request: state mismatch")
}
if token, err = config.Exchange(c.Context(), req.Code); err != nil {
log.Error("[C] oauth config exchange err: %s", err.Error())
return resp.Resp500(c, err.Error())
}
bs, _ := json.Marshal(token)
log.Info("[C] oauth finally token =\n%s", string(bs))
return resp.Resp200(c, token)
}

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
>
<title>Client Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<div>
<h3>这里是 xx 产品登录页面</h3>
<form>
<fieldset>
<label>
Username
<input
name="username"
placeholder="username"
autocomplete="given-name"
/>
</label>
<label>
Password
<input
type="password"
name="password"
placeholder="password"
autocomplete="password"
/>
</label>
</fieldset>
<input
type="submit"
value="登录"
/>
<a href="/login?oauth=true">使用 OAuth V2 账号登录</a>
</form>
</div>
</body>
</html>

16
internal/cmd/client.go Normal file
View File

@ -0,0 +1,16 @@
package cmd
import (
"github.com/spf13/cobra"
"uauth/internal/client"
)
func initClient() *cobra.Command {
return &cobra.Command{
Use: "client",
Short: "Run the client",
RunE: func(cmd *cobra.Command, args []string) error {
return client.Run(cmd.Context())
},
}
}

31
internal/cmd/cmd.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"github.com/loveuer/nf/nft/log"
"github.com/spf13/cobra"
"uauth/internal/opt"
)
var (
Command = &cobra.Command{
Use: "uauth",
Short: "uauth: oauth v2 server",
Example: "",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if opt.Cfg.Debug {
log.SetLogLevel(log.LogLevelDebug)
}
},
}
)
func init() {
Command.PersistentFlags().BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode")
initServe()
Command.AddCommand(
initServe(),
initClient(),
)
}

31
internal/cmd/serve.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"github.com/spf13/cobra"
"uauth/internal/opt"
"uauth/internal/serve"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
func initServe() *cobra.Command {
svc := &cobra.Command{
Use: "svc",
RunE: func(cmd *cobra.Command, args []string) error {
tool.TablePrinter(opt.Cfg)
tool.Must(cache.Init(opt.Cfg.Svc.Cache))
tool.Must(db.Init(cmd.Context(), opt.Cfg.Svc.DB))
tool.Must(model.Init(db.Default.Session()))
return serve.Run(cmd.Context())
},
}
svc.Flags().StringVar(&opt.Cfg.Svc.Address, "address", "localhost:8080", "listen address")
svc.Flags().StringVar(&opt.Cfg.Svc.Prefix, "prefix", "/oauth/v2", "api prefix")
svc.Flags().StringVar(&opt.Cfg.Svc.Cache, "cache", "lru::", "cache uri")
svc.Flags().StringVar(&opt.Cfg.Svc.DB, "db", "sqlite::data.sqlite", "database uri")
return svc
}

View File

@ -0,0 +1,22 @@
package interfaces
import (
"context"
"time"
)
type Cacher interface {
Get(ctx context.Context, key string) ([]byte, error)
GetScan(ctx context.Context, key string) Scanner
GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error)
GetExScan(ctx context.Context, key string, duration time.Duration) Scanner
// Set value 会被序列化, 优先使用 MarshalBinary 方法, 没有则执行 json.Marshal
Set(ctx context.Context, key string, value any) error
// SetEx value 会被序列化, 优先使用 MarshalBinary 方法, 没有则执行 json.Marshal
SetEx(ctx context.Context, key string, value any, duration time.Duration) error
Del(ctx context.Context, keys ...string) error
}
type Scanner interface {
Scan(model any) error
}

View File

@ -0,0 +1,11 @@
package interfaces
type Enum interface {
Value() int64
Code() string
Label() string
MarshalJSON() ([]byte, error)
All() []Enum
}

View File

@ -0,0 +1,7 @@
package interfaces
type OpLogger interface {
Enum
Render(content map[string]any) (string, error)
Template() string
}

View File

@ -0,0 +1,118 @@
package auth
import (
"errors"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"net/http"
"time"
"uauth/internal/store/cache"
"uauth/internal/tool"
"uauth/model"
)
type Config struct {
IgnoreFn func(c *nf.Ctx) bool
TokenFn func(c *nf.Ctx) (string, bool)
GetUserFn func(c *nf.Ctx, token string) (*model.User, error)
NextOnError bool
}
var (
defaultIgnoreFn = func(c *nf.Ctx) bool {
return false
}
defaultTokenFn = func(c *nf.Ctx) (string, bool) {
var token string
if token = c.Request.Header.Get("Authorization"); token != "" {
return token, true
}
if token = c.Query("access_token"); token != "" {
return token, true
}
if token = c.Cookies("access_token"); token != "" {
return token, true
}
return "", false
}
defaultGetUserFn = func(c *nf.Ctx, token string) (*model.User, error) {
var (
err error
op = new(model.User)
key = cache.Prefix + "token:" + token
)
if err = cache.Client.GetExScan(tool.Timeout(3), key, 24*time.Hour).Scan(op); err != nil {
if errors.Is(err, cache.ErrorKeyNotFound) {
return nil, err
}
log.Error("[M] cache client get user by token key = %s, err = %s", key, err.Error())
return nil, errors.New("Internal Server Error")
}
return op, nil
}
)
func New(cfgs ...*Config) nf.HandlerFunc {
var cfg = &Config{}
if len(cfgs) > 0 && cfgs[0] != nil {
cfg = cfgs[0]
}
if cfg.IgnoreFn == nil {
cfg.IgnoreFn = defaultIgnoreFn
}
if cfg.TokenFn == nil {
cfg.TokenFn = defaultTokenFn
}
if cfg.GetUserFn == nil {
cfg.GetUserFn = defaultGetUserFn
}
return func(c *nf.Ctx) error {
if cfg.IgnoreFn(c) {
return c.Next()
}
token, ok := cfg.TokenFn(c)
if !ok {
if cfg.NextOnError {
return c.Next()
}
return resp.Resp401(c, nil, "请登录")
}
op, err := cfg.GetUserFn(c, token)
if err != nil {
if cfg.NextOnError {
return c.Next()
}
if errors.Is(err, cache.ErrorKeyNotFound) {
return c.Status(http.StatusUnauthorized).JSON(map[string]any{
"status": 500,
"msg": "用户认证信息不存在或已过期, 请重新登录",
})
}
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
c.Locals("user", op)
return c.Next()
}
}

17
internal/opt/config.go Normal file
View File

@ -0,0 +1,17 @@
package opt
type svc struct {
Address string `json:"address"`
Prefix string `json:"prefix"`
Cache string `json:"cache"`
DB string `json:"db"`
}
type config struct {
Debug bool `json:"debug"`
Svc svc `json:"svc"`
}
var (
Cfg = config{}
)

6
internal/opt/var.go Normal file
View File

@ -0,0 +1,6 @@
package opt
const (
// 记得替换这个
JwtTokenSecret = "2(v6UW3pBf1Miz^bY9u4rAUyv&dj8Kdz"
)

View File

@ -0,0 +1,86 @@
package handler
import (
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"net/http"
"net/url"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/model"
)
//go:embed serve_approve.html
var pageApprove string
func Approve(c *nf.Ctx) error {
// 获取表单数据
type Req struct {
ClientId string `form:"client_id"`
RedirectURI string `form:"redirect_uri"`
Scope string `form:"scope"`
State string `form:"state"`
}
var (
ok bool
op *model.User
err error
req = new(Req)
uri *url.URL
client = new(model.Client)
)
if op, ok = c.Locals("user").(*model.User); !ok {
return resp.Resp401(c, nil)
}
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err)
}
if uri, err = url.Parse(req.RedirectURI); err != nil {
log.Warn("[S] parse redirect uri = %s, err = %s", req.RedirectURI, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid redirect uri")
}
if err = db.Default.Session().Where("client_id", req.ClientId).Take(client).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid client_id")
}
log.Error("[S] get client by id fail, client_id = %s, err = %s", req.ClientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
db.Default.Session().Clauses(clause.OnConflict{DoNothing: true}).
Create(&model.AuthorizationRecord{
UserId: op.Id,
ClientId: client.Id,
})
authorizationCode := uuid.New().String()[:8]
if err = cache.Client.SetEx(c.Context(), cache.Prefix+"auth_code:"+authorizationCode, op.Id, 10*time.Minute); err != nil {
return resp.Resp500(c, err)
}
qs := uri.Query()
qs.Add("code", authorizationCode)
qs.Add("client_id", req.ClientId)
qs.Add("scope", req.Scope)
qs.Add("state", req.State)
uri.ForceQuery = true
value := uri.String() + qs.Encode()
return c.RenderHTML("approve", pageApprove, map[string]interface{}{
"redirect_uri": value,
})
}

View File

@ -0,0 +1,121 @@
package handler
import (
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"net/http"
"net/url"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/model"
)
var (
//go:embed serve_authorize.html
pageAuthorize string
)
func Authorize(c *nf.Ctx) error {
type Req struct {
ClientId string `query:"client_id"`
ResponseType string `query:"response_type"`
RedirectURI string `query:"redirect_uri"`
Scope string `query:"scope"`
State string `query:"state"`
}
var (
ok bool
op *model.User
req = new(Req)
err error
client = &model.Client{}
authRecord = &model.AuthorizationRecord{}
uri *url.URL
)
if err = c.QueryParser(req); err != nil {
log.Error("[S] query parser err = %s", err.Error())
return c.Status(http.StatusBadRequest).SendString("Invalid request")
}
if req.ResponseType != "code" {
log.Warn("[S] response type = %s", req.ResponseType)
return c.Status(http.StatusBadRequest).SendString("Invalid request")
}
// 如果未登录,则跳转到登录界面
if op, ok = c.Locals("user").(*model.User); !ok {
log.Info("[S] op not logined, redirect to login page")
return c.Redirect("/oauth/v2/login?"+c.Request.URL.Query().Encode(), http.StatusFound)
}
log.Info("[S] Authorize: username = %s, client_id = %s", op.Username, req.ClientId)
if err = db.Default.Session().Where("client_id", req.ClientId).Take(client).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid client_id")
}
log.Error("[Authorize]: db take clients err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if err = db.Default.Session().Model(&model.AuthorizationRecord{}).
Where("user_id", op.Id).
Where("client_id", client.Id).
Take(authRecord).
Error; err != nil {
// 用户第一次对该 client 进行授权
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.RenderHTML("authorize", pageAuthorize, map[string]any{
"user": map[string]any{
"username": op.Username,
"avatar": "https://picsum.photos/200",
},
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
})
}
log.Error("[Authorize]: db take authorization_records err = %s", err.Error())
return resp.Resp500(c, err)
}
// 当用户已经授权过时
// 生成授权码并缓存授权码
log.Debug("[Authorize]: username = %s already approved %s", op.Username, client.Name)
authorizationCode := uuid.New().String()[:8]
if err = cache.Client.SetEx(c.Context(), cache.Prefix+"auth_code:"+authorizationCode, op.Id, 10*time.Minute); err != nil {
return resp.Resp500(c, err)
}
if uri, err = url.Parse(req.RedirectURI); err != nil {
log.Warn("[S] parse redirect uri = %s, err = %s", req.RedirectURI, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid redirect uri")
}
qs := uri.Query()
qs.Add("code", authorizationCode)
qs.Add("client_id", req.ClientId)
qs.Add("scope", req.Scope)
qs.Add("state", req.State)
uri.ForceQuery = true
value := uri.String() + qs.Encode()
return c.RenderHTML("approve", pageApprove, map[string]interface{}{
"redirect_uri": value,
})
}

View File

@ -0,0 +1,137 @@
package handler
import (
_ "embed"
"errors"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"net/http"
"time"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
var (
//go:embed serve_login.html
pageLogin string
)
func LoginPage(c *nf.Ctx) error {
type Req struct {
ClientId string `query:"client_id" json:"client_id"`
Scope string `query:"scope" json:"scope"`
RedirectURI string `query:"redirect_uri" json:"redirect_uri"`
State string `query:"state" json:"state"`
ResponseType string `query:"response_type" json:"response_type"`
}
var (
err error
req = new(Req)
client = new(model.Client)
)
if err = c.QueryParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if req.ClientId == "" || req.RedirectURI == "" {
return resp.Resp400(c, req)
}
if err = db.Default.Session().Model(&model.Client{}).
Where("client_id = ?", req.ClientId).
Take(client).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusForbidden).SendString("Client Not Registry")
}
log.Error("[S] model take client id = %s, err = %s", req.ClientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
return c.RenderHTML("login", pageLogin, map[string]interface{}{
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
"response_type": req.ResponseType,
"client_name": client.Name,
"client_icon": client.Icon,
})
}
//go:embed serve_login_success.html
var pageLoginSuccess string
func LoginAction(c *nf.Ctx) error {
type Req struct {
Username string `form:"username"`
Password string `form:"password"`
ClientId string `form:"client_id"`
RedirectURI string `form:"redirect_uri"`
Scope string `form:"scope"`
State string `form:"state"`
ResponseType string `form:"response_type"`
}
var (
err error
req = new(Req)
op = new(model.User)
token string
)
if err = c.BodyParser(req); err != nil {
log.Warn("[S] LoginAction: body parser err = %s", err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
if req.Username == "" || req.Password == "" {
return c.Status(http.StatusBadRequest).SendString("Bad Request: username, password is required")
}
if err = db.Default.Session().Model(&model.User{}).
Where("username = ?", req.Username).
Take(op).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn("[S] LoginAction: username = %s not found", req.Username)
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
log.Error("[S] LoginAction: model take username = %s, err = %s", req.Username, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
// todo: 验证用户登录是否成功,等等
if !tool.ComparePassword(req.Password, op.Password) {
log.Warn("[S] LoginAction: model take username = %s, password is invalid", req.Username)
return c.Status(http.StatusBadRequest).SendString("Bad Request")
}
if token, err = op.JwtEncode(); err != nil {
log.Error("[S] LoginAction: jwtEncode err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
key := cache.Prefix + "token:" + token
if err = cache.Client.SetEx(c.Context(), key, op, 24*time.Hour); err != nil {
log.Error("[S] LoginAction: cache SetEx err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
c.Writer.Header().Add("Set-Cookie", "access_token="+token)
return c.RenderHTML("login_success", pageLoginSuccess, map[string]interface{}{
"client_id": req.ClientId,
"redirect_uri": req.RedirectURI,
"scope": req.Scope,
"state": req.State,
"response_type": req.ResponseType,
})
}

View File

@ -0,0 +1,111 @@
package handler
import (
_ "embed"
"errors"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/resp"
"gorm.io/gorm"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
func ClientRegistry(c *nf.Ctx) error {
type Req struct {
ClientId string `json:"client_id"`
Icon string `json:"icon"` // url
Name string `json:"name"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
Secret := tool.RandomString(32)
platform := &model.Client{
ClientId: req.ClientId,
Icon: req.Icon,
Name: req.Name,
ClientSecret: Secret,
}
if err = db.Default.Session().Create(platform).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return resp.Resp400(c, err, "当前平台已经存在")
}
return resp.Resp500(c, err)
}
return resp.Resp200(c, platform)
}
var (
//go:embed serve_registry.html
registryLogin string
)
func UserRegistryPage(c *nf.Ctx) error {
return c.HTML(registryLogin)
}
func UserRegistryAction(c *nf.Ctx) error {
type Req struct {
Username string `form:"username"`
Nickname string `form:"nickname"`
Password string `form:"password"`
ConfirmPassword string `form:"confirm_password"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return resp.Resp400(c, err.Error())
}
if err = tool.CheckPassword(req.Password); err != nil {
return resp.Resp400(c, req, err.Error())
}
op := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Password: tool.NewPassword(req.Password),
}
if err = db.Default.Session().Create(op).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return resp.Resp400(c, err, "用户名已存在")
}
return resp.Resp500(c, err)
}
return c.HTML(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>注册成功</title>
</head>
<body>
<h1>注册成功</h1>
<h3>快去试试吧</h3>
</body>
</html>
`)
}

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>授权成功</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<span aria-busy="true">授权成功, 正在跳转回原网页...</span>
<div style="display: none">
<input type="hidden" id="redirect_uri" value="{{ .redirect_uri }}"/>
</div>
<script type="text/javascript">
setTimeout(() => {
console.log('[D] after 1s console')
let redirect_uri = document.getElementById('redirect_uri').value
window.location.href = redirect_uri
}, 1000)
</script>
</body>
</html>

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<div>
<h3>授权登录到 {{ .client_name }} 平台</h3>
<div class="userinfo">
<article style="display: flex; align-items: center;">
<div style="height:50px; width:50px;">
<img src="{{ .user.avatar }}"/>
</div>
<div style="margin-left:20px; ">
{{ .user.username }}
</div>
</article>
</div>
<form action="/oauth/v2/approve" method="POST">
<fieldset>
<input type="hidden" name="client_id" value="{{ .client_id }}"/>
<input type="hidden" name="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" name="scope" value="{{ .scope }}"/>
<input type="hidden" name="state" value="{{ .state }}"/>
</fieldset>
<div style="display: flex;">
<button type="button" class="contrast" style="flex:1; margin-right: 10px">取消</button>
<button type="submit" style="flex: 1;">授权</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
div.row {
display: flex;
align-items: center;
max-width: 33%;
height: 50px;
overflow: hidden;
flex: 1;
}
div.row:nth-child(2) {
justify-content: center;
}
div.row:last-child {
margin-left: auto;
}
</style>
</head>
<body>
<div>
<h3>欢迎来到 UAuth</h3>
<article style="display: flex; align-items: center;">
<div class="row">
<div style="height:50px; width:50px;">
<img src="https://picsum.photos/seed/drealism/200"/>
</div>
<div style="margin-left:10px; ">UAuth</div>
</div>
<div style="transform: rotate(90deg);" class="row">
<svg t="1730168415342" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1981" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40"><path d="M428.3 66.4c-12-5-25.7-2.2-34.9 6.9l-320 319.6c-12.5 12.5-12.5 32.7 0 45.3 12.5 12.5 32.7 12.5 45.3 0l265.4-265V928c0 17.7 14.3 32 32 32s32-14.3 32-32V96c-0.1-12.9-7.9-24.6-19.8-29.6zM950.6 585.8c-12.5-12.5-32.8-12.5-45.3 0L640 850.8V96c0-17.7-14.3-32-32-32s-32 14.3-32 32v832c0 12.9 7.8 24.6 19.7 29.6 4 1.6 8.1 2.4 12.2 2.4 8.3 0 16.5-3.2 22.6-9.4l320-319.6c12.6-12.4 12.6-32.7 0.1-45.2z" p-id="1982"></path></svg>
</div>
<div class="row">
<div style="height:50px; width:50px;">
<img src="{{ .client_icon }}"/>
</div>
<div style="margin-left:10px; ">
{{ .client_name }}
</div>
</div>
</article>
<form action="/oauth/v2/login" method="POST">
<fieldset>
<label>
Username
<input
name="username"
placeholder="username"
autocomplete="given-name"
/>
</label>
<label>
Password
<input
type="password"
name="password"
placeholder="password"
autocomplete="password"
/>
</label>
<input type="hidden" name="client_id" value="{{ .client_id }}"/>
<input type="hidden" name="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" name="scope" value="{{ .scope }}"/>
<input type="hidden" name="state" value="{{ .state }}" />
<input type="hidden" name="response_type" value="{{ .response_type }}" />
</fieldset>
<input
type="submit"
value="登录"
/>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录成功</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<span aria-busy="true">登录成功, 正在跳转...</span>
<div style="display: none">
<input type="hidden" id="client_id" value="{{ .client_id }}"/>
<input type="hidden" id="scope" value="{{ .scope }}"/>
<input type="hidden" id="state" value="{{ .state }}"/>
<input type="hidden" id="redirect_uri" value="{{ .redirect_uri }}"/>
<input type="hidden" id="response_type" value="{{ .response_type }}"/>
</div>
<script type="text/javascript">
setTimeout(() => {
console.log('[D] after 1s console')
let client_id = document.querySelector('#client_id').value;
let scope = document.querySelector('#scope').value;
let state = document.querySelector('#state').value;
let redirect_uri = document.querySelector('#redirect_uri').value;
let response_type = document.querySelector('#response_type').value;
window.location.href = `/oauth/v2/authorize?client_id=${client_id}&scope=${scope}&redirect_uri=${redirect_uri}&state=${state}&response_type=${response_type}`;
}, 1000)
</script>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.jade.min.css"
>
<title>Server Login</title>
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<div>
<h3>欢迎注册 UAuth</h3>
<form action="/api/oauth/v2/registry/user" method="POST" id="form">
<fieldset>
<label>
用户名
<input id="username" name="username" autocomplete="given-name"/>
</label>
<label>
昵称
<input id="nickname" name="nickname" autocomplete="given-name"/>
</label>
<label>
密码
<input id="password" type="password" name="password" autocomplete="password"/>
</label>
<label>
重复密码
<input id="confirm_password" type="password" name="confirm_password" autocomplete="password"/>
</label>
<button type="button" style="flex: 1;width: 100%;" onclick="registry()">注册</button>
</fieldset>
</form>
</div>
<script type="text/javascript">
function registry() {
let user = {
username: document.querySelector("#username").value,
nickname: document.querySelector("#nickname").value,
password: document.querySelector("#password").value,
confirm_password: document.querySelector("#confirm_password").value,
}
console.log('[D] user = ', user)
if (!user.username || !user.password || !user.nickname) {
window.alert("参数均不能为空")
return
}
if (user.password !== user.confirm_password) {
window.alert("两次密码不一致")
return
}
document.querySelector("#form").submit()
}
</script>
</body>
</html>

View File

@ -0,0 +1,109 @@
package handler
import (
"encoding/base64"
"errors"
"github.com/google/uuid"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"gorm.io/gorm"
"net/http"
"strings"
"uauth/internal/store/cache"
"uauth/internal/store/db"
"uauth/internal/tool"
"uauth/model"
)
func HandleToken(c *nf.Ctx) error {
type Req struct {
Code string `form:"code"`
GrantType string `form:"grant_type"`
RedirectURI string `form:"redirect_uri"`
}
var (
err error
req = new(Req)
opId uint64
op = new(model.User)
token string
basic string
bs []byte
strs []string
client = new(model.Client)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid form")
}
// client_secret
if basic = c.Get("Authorization"); basic == "" {
return c.Status(http.StatusUnauthorized).SendString("Authorization header missing")
}
switch {
case strings.HasPrefix(basic, "Basic "):
basic = strings.TrimPrefix(basic, "Basic ")
default:
return c.Status(http.StatusBadRequest).SendString("Bad Request: authorization scheme not supported")
}
if bs, err = base64.StdEncoding.DecodeString(basic); err != nil {
log.Warn("[Token] base64 decode failed, raw = %s, err = %s", basic, err.Error())
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid basic authorization")
}
if strs = strings.SplitN(string(bs), ":", 2); len(strs) != 2 {
log.Warn("[Token] basic split err, decode = %s", string(bs))
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid basic authorization")
}
clientId, clientSecret := strs[0], strs[1]
if err = db.Default.Session().Model(&model.Client{}).
Where("client_id", clientId).
Take(client).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: client invalid")
}
log.Error("[Token] db take client by id = %s, err = %s", clientId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if client.ClientSecret != clientSecret {
log.Warn("[Token] client_secret invalid, want = %s, got = %s", client.ClientSecret, clientSecret)
return c.Status(http.StatusUnauthorized).SendString("Unauthorized: client secret invalid")
}
if err = cache.Client.GetScan(tool.Timeout(2), cache.Prefix+"auth_code:"+req.Code).Scan(&opId); err != nil {
if errors.Is(err, cache.ErrorKeyNotFound) {
return c.Status(http.StatusBadRequest).SendString("Bad Request: invalid code")
}
log.Error("[S] handleToken: get code from cache err = %s", err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
op.Id = opId
if err = db.Default.Session().Take(op).Error; err != nil {
log.Error("[S] handleToken: get op by id err, id = %d, err = %s", opId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
if token, err = op.JwtEncode(); err != nil {
log.Error("[S] handleToken: encode token err, id = %d, err = %s", opId, err.Error())
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
refreshToken := uuid.New().String()
return c.JSON(map[string]any{
"access_token": token,
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": 24 * 3600,
})
}

39
internal/serve/serve.go Normal file
View File

@ -0,0 +1,39 @@
package serve
import (
"context"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"uauth/internal/middleware/auth"
"uauth/internal/opt"
"uauth/internal/serve/handler"
"uauth/internal/tool"
)
func Run(ctx context.Context) error {
app := nf.New()
api := app.Group(opt.Cfg.Svc.Prefix)
api.Get("/registry/user", handler.UserRegistryPage)
api.Post("/registry/user", handler.UserRegistryAction)
api.Post("/registry/client", handler.ClientRegistry)
api.Get("/login", handler.LoginPage)
api.Post("/login", handler.LoginAction)
api.Get("/authorize", auth.New(&auth.Config{NextOnError: true}), handler.Authorize)
api.Post("/approve", auth.New(), handler.Approve)
api.Post("/token", handler.HandleToken)
api.Get("/after", auth.New(), func(c *nf.Ctx) error {
return c.SendString("hello world")
})
log.Info("Starting server on: %s", opt.Cfg.Svc.Address)
go func() {
<-ctx.Done()
_ = app.Shutdown(tool.Timeout(2))
}()
return app.Run(opt.Cfg.Svc.Address)
}

117
internal/store/cache/cache_lru.go vendored Normal file
View File

@ -0,0 +1,117 @@
package cache
import (
"context"
"github.com/hashicorp/golang-lru/v2/expirable"
_ "github.com/hashicorp/golang-lru/v2/expirable"
"time"
"uauth/internal/interfaces"
)
var _ interfaces.Cacher = (*_lru)(nil)
type _lru struct {
client *expirable.LRU[string, *_lru_value]
}
type _lru_value struct {
duration time.Duration
last time.Time
bs []byte
}
func (l *_lru) Get(ctx context.Context, key string) ([]byte, error) {
v, ok := l.client.Get(key)
if !ok {
return nil, ErrorKeyNotFound
}
if v.duration == 0 {
return v.bs, nil
}
if time.Now().Sub(v.last) > v.duration {
l.client.Remove(key)
return nil, ErrorKeyNotFound
}
return v.bs, nil
}
func (l *_lru) GetScan(ctx context.Context, key string) interfaces.Scanner {
return newScanner(l.Get(ctx, key))
}
func (l *_lru) GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error) {
v, ok := l.client.Get(key)
if !ok {
return nil, ErrorKeyNotFound
}
if v.duration == 0 {
return v.bs, nil
}
now := time.Now()
if now.Sub(v.last) > v.duration {
l.client.Remove(key)
return nil, ErrorKeyNotFound
}
l.client.Add(key, &_lru_value{
duration: duration,
last: now,
bs: v.bs,
})
return v.bs, nil
}
func (l *_lru) GetExScan(ctx context.Context, key string, duration time.Duration) interfaces.Scanner {
return newScanner(l.GetEx(ctx, key, duration))
}
func (l *_lru) Set(ctx context.Context, key string, value any) error {
bs, err := handleValue(value)
if err != nil {
return err
}
l.client.Add(key, &_lru_value{
duration: 0,
last: time.Now(),
bs: bs,
})
return nil
}
func (l *_lru) SetEx(ctx context.Context, key string, value any, duration time.Duration) error {
bs, err := handleValue(value)
if err != nil {
return err
}
l.client.Add(key, &_lru_value{
duration: duration,
last: time.Now(),
bs: bs,
})
return nil
}
func (l *_lru) Del(ctx context.Context, keys ...string) error {
for _, key := range keys {
l.client.Remove(key)
}
return nil
}
func newLRUCache() (interfaces.Cacher, error) {
client := expirable.NewLRU[string, *_lru_value](1024*1024, nil, 0)
return &_lru{client: client}, nil
}

82
internal/store/cache/cache_memory.go vendored Normal file
View File

@ -0,0 +1,82 @@
package cache
import (
"context"
"errors"
"fmt"
"time"
"uauth/internal/interfaces"
"gitea.com/taozitaozi/gredis"
)
var _ interfaces.Cacher = (*_mem)(nil)
type _mem struct {
client *gredis.Gredis
}
func (m *_mem) GetScan(ctx context.Context, key string) interfaces.Scanner {
return newScanner(m.Get(ctx, key))
}
func (m *_mem) GetExScan(ctx context.Context, key string, duration time.Duration) interfaces.Scanner {
return newScanner(m.GetEx(ctx, key, duration))
}
func (m *_mem) Get(ctx context.Context, key string) ([]byte, error) {
v, err := m.client.Get(key)
if err != nil {
if errors.Is(err, gredis.ErrKeyNotFound) {
return nil, ErrorKeyNotFound
}
return nil, err
}
bs, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("invalid value type=%T", v)
}
return bs, nil
}
func (m *_mem) GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error) {
v, err := m.client.GetEx(key, duration)
if err != nil {
if errors.Is(err, gredis.ErrKeyNotFound) {
return nil, ErrorKeyNotFound
}
return nil, err
}
bs, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("invalid value type=%T", v)
}
return bs, nil
}
func (m *_mem) Set(ctx context.Context, key string, value any) error {
bs, err := handleValue(value)
if err != nil {
return err
}
return m.client.Set(key, bs)
}
func (m *_mem) SetEx(ctx context.Context, key string, value any, duration time.Duration) error {
bs, err := handleValue(value)
if err != nil {
return err
}
return m.client.SetEx(key, bs, duration)
}
func (m *_mem) Del(ctx context.Context, keys ...string) error {
m.client.Delete(keys...)
return nil
}

72
internal/store/cache/cache_redis.go vendored Normal file
View File

@ -0,0 +1,72 @@
package cache
import (
"context"
"errors"
"github.com/go-redis/redis/v8"
"time"
"uauth/internal/interfaces"
)
type _redis struct {
client *redis.Client
}
func (r *_redis) Get(ctx context.Context, key string) ([]byte, error) {
result, err := r.client.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrorKeyNotFound
}
return nil, err
}
return []byte(result), nil
}
func (r *_redis) GetScan(ctx context.Context, key string) interfaces.Scanner {
return newScanner(r.Get(ctx, key))
}
func (r *_redis) GetEx(ctx context.Context, key string, duration time.Duration) ([]byte, error) {
result, err := r.client.GetEx(ctx, key, duration).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrorKeyNotFound
}
return nil, err
}
return []byte(result), nil
}
func (r *_redis) GetExScan(ctx context.Context, key string, duration time.Duration) interfaces.Scanner {
return newScanner(r.GetEx(ctx, key, duration))
}
func (r *_redis) Set(ctx context.Context, key string, value any) error {
bs, err := handleValue(value)
if err != nil {
return err
}
_, err = r.client.Set(ctx, key, bs, redis.KeepTTL).Result()
return err
}
func (r *_redis) SetEx(ctx context.Context, key string, value any, duration time.Duration) error {
bs, err := handleValue(value)
if err != nil {
return err
}
_, err = r.client.SetEX(ctx, key, bs, duration).Result()
return err
}
func (r *_redis) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err()
}

42
internal/store/cache/client.go vendored Normal file
View File

@ -0,0 +1,42 @@
package cache
import (
"encoding/json"
"uauth/internal/interfaces"
)
const (
Prefix = "sys:uauth:"
)
var (
Client interfaces.Cacher
)
type encoded_value interface {
MarshalBinary() ([]byte, error)
}
type decoded_value interface {
UnmarshalBinary(bs []byte) error
}
func handleValue(value any) ([]byte, error) {
var (
bs []byte
err error
)
switch value.(type) {
case []byte:
return value.([]byte), nil
}
if imp, ok := value.(encoded_value); ok {
bs, err = imp.MarshalBinary()
} else {
bs, err = json.Marshal(value)
}
return bs, err
}

7
internal/store/cache/error.go vendored Normal file
View File

@ -0,0 +1,7 @@
package cache
import "errors"
var (
ErrorKeyNotFound = errors.New("key not found")
)

69
internal/store/cache/init.go vendored Normal file
View File

@ -0,0 +1,69 @@
package cache
import (
"fmt"
"gitea.com/taozitaozi/gredis"
"github.com/go-redis/redis/v8"
"net/url"
"strings"
"uauth/internal/tool"
)
func Init(uri string) error {
var (
err error
)
strs := strings.Split(uri, "::")
switch strs[0] {
case "memory":
gc := gredis.NewGredis(1024 * 1024)
Client = &_mem{client: gc}
case "lru":
if Client, err = newLRUCache(); err != nil {
return err
}
case "redis":
var (
ins *url.URL
err error
)
if len(strs) != 2 {
return fmt.Errorf("cache.Init: invalid cache uri: %s", uri)
}
uri := strs[1]
if !strings.Contains(uri, "://") {
uri = fmt.Sprintf("redis://%s", uri)
}
if ins, err = url.Parse(uri); err != nil {
return fmt.Errorf("cache.Init: url parse cache uri: %s, err: %s", uri, err.Error())
}
addr := ins.Host
username := ins.User.Username()
password, _ := ins.User.Password()
var rc *redis.Client
rc = redis.NewClient(&redis.Options{
Addr: addr,
Username: username,
Password: password,
})
if err = rc.Ping(tool.Timeout(5)).Err(); err != nil {
return fmt.Errorf("cache.Init: redis ping err: %s", err.Error())
}
Client = &_redis{client: rc}
default:
return fmt.Errorf("cache type %s not support", strs[0])
}
return nil
}

20
internal/store/cache/scan.go vendored Normal file
View File

@ -0,0 +1,20 @@
package cache
import "encoding/json"
type scanner struct {
err error
bs []byte
}
func (s *scanner) Scan(model any) error {
if s.err != nil {
return s.err
}
return json.Unmarshal(s.bs, model)
}
func newScanner(bs []byte, err error) *scanner {
return &scanner{bs: bs, err: err}
}

View File

@ -0,0 +1,45 @@
package db
import (
"context"
"uauth/internal/opt"
"uauth/internal/tool"
"gorm.io/gorm"
)
var (
Default *Client
)
type Client struct {
ctx context.Context
cli *gorm.DB
ttype string
}
func (c *Client) Type() string {
return c.ttype
}
func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
var ctx context.Context
if len(ctxs) > 0 && ctxs[0] != nil {
ctx = ctxs[0]
} else {
ctx = tool.Timeout(30)
}
session := c.cli.Session(&gorm.Session{Context: ctx})
if opt.Cfg.Debug {
session = session.Debug()
}
return session
}
func (c *Client) Close() {
d, _ := c.cli.DB()
d.Close()
}

View File

@ -0,0 +1,9 @@
package db
import (
"testing"
)
func TestOpen(t *testing.T) {
}

52
internal/store/db/init.go Normal file
View File

@ -0,0 +1,52 @@
package db
import (
"context"
"fmt"
"strings"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func New(ctx context.Context, uri string) (*Client, error) {
strs := strings.Split(uri, "::")
if len(strs) != 2 {
return nil, fmt.Errorf("db.Init: opt db uri invalid: %s", uri)
}
c := &Client{ttype: strs[0]}
var (
err error
dsn = strs[1]
)
switch strs[0] {
case "sqlite":
c.cli, err = gorm.Open(sqlite.Open(dsn))
case "mysql":
c.cli, err = gorm.Open(mysql.Open(dsn))
case "postgres":
c.cli, err = gorm.Open(postgres.Open(dsn))
default:
return nil, fmt.Errorf("db type only support: [sqlite, mysql, postgres], unsupported db type: %s", strs[0])
}
if err != nil {
return nil, fmt.Errorf("db.Init: open %s with dsn:%s, err: %w", strs[0], dsn, err)
}
return c, nil
}
func Init(ctx context.Context, uri string) (err error) {
if Default, err = New(ctx, uri); err != nil {
return err
}
return nil
}

104
internal/tool/cert.go Normal file
View File

@ -0,0 +1,104 @@
package tool
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"time"
)
func GenerateTlsConfig() (serverTLSConf *tls.Config, clientTLSConf *tls.Config, err error) {
ca := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Company, INC."},
Country: []string{"US"},
Province: []string{"California"},
Locality: []string{"San Francisco"},
StreetAddress: []string{"Golden Gate Bridge"},
PostalCode: []string{"94016"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(99, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
// create our private and public key
caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
// create the CA
caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
if err != nil {
return nil, nil, err
}
// pem encode
caPEM := new(bytes.Buffer)
pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
caPrivKeyPEM := new(bytes.Buffer)
pem.Encode(caPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey),
})
// set up our server certificate
cert := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Company, INC."},
Country: []string{"US"},
Province: []string{"California"},
Locality: []string{"San Francisco"},
StreetAddress: []string{"Golden Gate Bridge"},
PostalCode: []string{"94016"},
},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey)
if err != nil {
return nil, nil, err
}
certPEM := new(bytes.Buffer)
pem.Encode(certPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
certPrivKeyPEM := new(bytes.Buffer)
pem.Encode(certPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
})
serverCert, err := tls.X509KeyPair(certPEM.Bytes(), certPrivKeyPEM.Bytes())
if err != nil {
return nil, nil, err
}
serverTLSConf = &tls.Config{
Certificates: []tls.Certificate{serverCert},
}
certpool := x509.NewCertPool()
certpool.AppendCertsFromPEM(caPEM.Bytes())
clientTLSConf = &tls.Config{
RootCAs: certpool,
}
return
}

38
internal/tool/ctx.go Normal file
View File

@ -0,0 +1,38 @@
package tool
import (
"context"
"time"
)
func Timeout(seconds ...int) (ctx context.Context) {
var (
duration time.Duration
)
if len(seconds) > 0 && seconds[0] > 0 {
duration = time.Duration(seconds[0]) * time.Second
} else {
duration = time.Duration(30) * time.Second
}
ctx, _ = context.WithTimeout(context.Background(), duration)
return
}
func TimeoutCtx(ctx context.Context, seconds ...int) context.Context {
var (
duration time.Duration
)
if len(seconds) > 0 && seconds[0] > 0 {
duration = time.Duration(seconds[0]) * time.Second
} else {
duration = time.Duration(30) * time.Second
}
nctx, _ := context.WithTimeout(ctx, duration)
return nctx
}

30
internal/tool/file.go Normal file
View File

@ -0,0 +1,30 @@
package tool
import (
"io"
"os"
)
func CopyFile(src string, dst string) (err error) {
// Open the source file
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create the destination file
destinationFile, err := os.Create(dst)
if err != nil {
return err
}
defer destinationFile.Close()
// Copy the contents from source to destination
_, err = io.Copy(destinationFile, sourceFile)
if err != nil {
return err
}
return nil
}

24
internal/tool/human.go Normal file
View File

@ -0,0 +1,24 @@
package tool
import "fmt"
func HumanDuration(nano int64) string {
duration := float64(nano)
unit := "ns"
if duration >= 1000 {
duration /= 1000
unit = "us"
}
if duration >= 1000 {
duration /= 1000
unit = "ms"
}
if duration >= 1000 {
duration /= 1000
unit = " s"
}
return fmt.Sprintf("%6.2f%s", duration, unit)
}

11
internal/tool/must.go Normal file
View File

@ -0,0 +1,11 @@
package tool
import "github.com/loveuer/nf/nft/log"
func Must(errs ...error) {
for _, err := range errs {
if err != nil {
log.Panic(err.Error())
}
}
}

84
internal/tool/password.go Normal file
View File

@ -0,0 +1,84 @@
package tool
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/loveuer/nf/nft/log"
"golang.org/x/crypto/pbkdf2"
"regexp"
"strconv"
"strings"
)
const (
EncryptHeader string = "pbkdf2:sha256" // 用户密码加密
)
func NewPassword(password string) string {
return EncryptPassword(password, RandomString(8), int(RandomInt(50000)+100000))
}
func ComparePassword(in, db string) bool {
strs := strings.Split(db, "$")
if len(strs) != 3 {
log.Error("password in db invalid: %s", db)
return false
}
encs := strings.Split(strs[0], ":")
if len(encs) != 3 {
log.Error("password in db invalid: %s", db)
return false
}
encIteration, err := strconv.Atoi(encs[2])
if err != nil {
log.Error("password in db invalid: %s, convert iter err: %s", db, err)
return false
}
return EncryptPassword(in, strs[1], encIteration) == db
}
func EncryptPassword(password, salt string, iter int) string {
hash := pbkdf2.Key([]byte(password), []byte(salt), iter, 32, sha256.New)
encrypted := hex.EncodeToString(hash)
return fmt.Sprintf("%s:%d$%s$%s", EncryptHeader, iter, salt, encrypted)
}
func CheckPassword(password string) error {
if len(password) < 8 || len(password) > 32 {
return errors.New("密码长度不符合")
}
var (
err error
match bool
patternList = []string{`[0-9]+`, `[a-z]+`, `[A-Z]+`, `[!@#%]+`} //, `[~!@#$%^&*?_-]+`}
matchAccount = 0
tips = []string{"缺少数字", "缺少小写字母", "缺少大写字母", "缺少'!@#%'"}
locktips = make([]string, 0)
)
for idx, pattern := range patternList {
match, err = regexp.MatchString(pattern, password)
if err != nil {
log.Warn("regex match string err, reg_str: %s, err: %v", pattern, err)
return errors.New("密码强度不够")
}
if match {
matchAccount++
} else {
locktips = append(locktips, tips[idx])
}
}
if matchAccount < 3 {
return fmt.Errorf("密码强度不够, 可能 %s", strings.Join(locktips, ", "))
}
return nil
}

View File

@ -0,0 +1,11 @@
package tool
import "testing"
func TestEncPassword(t *testing.T) {
password := "123456"
result := EncryptPassword(password, RandomString(8), 50000)
t.Logf("sum => %s", result)
}

54
internal/tool/random.go Normal file
View File

@ -0,0 +1,54 @@
package tool
import (
"crypto/rand"
"math/big"
)
var (
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterNum = []byte("0123456789")
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterSyb = []byte("!@#$%^&*()_+-=")
)
func RandomInt(max int64) int64 {
num, _ := rand.Int(rand.Reader, big.NewInt(max))
return num.Int64()
}
func RandomString(length int) string {
result := make([]byte, length)
for i := 0; i < length; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
result[i] = letters[num.Int64()]
}
return string(result)
}
func RandomPassword(length int, withSymbol bool) string {
result := make([]byte, length)
kind := 3
if withSymbol {
kind++
}
for i := 0; i < length; i++ {
switch i % kind {
case 0:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterNum))))
result[i] = letterNum[num.Int64()]
case 1:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterLow))))
result[i] = letterLow[num.Int64()]
case 2:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterCap))))
result[i] = letterCap[num.Int64()]
case 3:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterSyb))))
result[i] = letterSyb[num.Int64()]
}
}
return string(result)
}

5
internal/tool/slice.go Normal file
View File

@ -0,0 +1,5 @@
package tool
func Bulk[T any](slice []T, size int) {
// todo
}

View File

@ -0,0 +1 @@
package tool

124
internal/tool/table.go Normal file
View File

@ -0,0 +1,124 @@
package tool
import (
"encoding/json"
"fmt"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/loveuer/nf/nft/log"
"io"
"os"
"reflect"
"strings"
)
func TablePrinter(data any, writers ...io.Writer) {
var w io.Writer = os.Stdout
if len(writers) > 0 && writers[0] != nil {
w = writers[0]
}
t := table.NewWriter()
structPrinter(t, "", data)
_, _ = fmt.Fprintln(w, t.Render())
}
func structPrinter(w table.Writer, prefix string, item any) {
Start:
rv := reflect.ValueOf(item)
if rv.IsZero() {
return
}
for rv.Type().Kind() == reflect.Pointer {
rv = rv.Elem()
}
switch rv.Type().Kind() {
case reflect.Invalid,
reflect.Uintptr,
reflect.Chan,
reflect.Func,
reflect.UnsafePointer:
case reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Interface:
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), rv.Interface()})
case reflect.String:
val := rv.String()
if len(val) <= 160 {
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val})
return
}
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val[0:64] + "..." + val[len(val)-64:]})
case reflect.Array, reflect.Slice:
for i := 0; i < rv.Len(); i++ {
p := strings.Join([]string{prefix, fmt.Sprintf("[%d]", i)}, ".")
structPrinter(w, p, rv.Index(i).Interface())
}
case reflect.Map:
for _, k := range rv.MapKeys() {
structPrinter(w, fmt.Sprintf("%s.{%v}", prefix, k), rv.MapIndex(k).Interface())
}
case reflect.Pointer:
goto Start
case reflect.Struct:
for i := 0; i < rv.NumField(); i++ {
p := fmt.Sprintf("%s.%s", prefix, rv.Type().Field(i).Name)
field := rv.Field(i)
//log.Debug("TablePrinter: prefix: %s, field: %v", p, rv.Field(i))
if !field.CanInterface() {
return
}
structPrinter(w, p, field.Interface())
}
}
}
func TableMapPrinter(data []byte) {
m := make(map[string]any)
if err := json.Unmarshal(data, &m); err != nil {
log.Warn(err.Error())
return
}
t := table.NewWriter()
addRow(t, "", m)
fmt.Println(t.Render())
}
func addRow(w table.Writer, prefix string, m any) {
rv := reflect.ValueOf(m)
switch rv.Type().Kind() {
case reflect.Map:
for _, k := range rv.MapKeys() {
key := k.String()
if prefix != "" {
key = strings.Join([]string{prefix, k.String()}, ".")
}
addRow(w, key, rv.MapIndex(k).Interface())
}
case reflect.Slice, reflect.Array:
for i := 0; i < rv.Len(); i++ {
addRow(w, fmt.Sprintf("%s[%d]", prefix, i), rv.Index(i).Interface())
}
default:
w.AppendRow(table.Row{prefix, m})
}
}

13
internal/tool/time.go Normal file
View File

@ -0,0 +1,13 @@
package tool
import "time"
// TodayMidnight 返回今日凌晨
func TodayMidnight() (midnight time.Time) {
now := time.Now()
year, month, day := now.Date()
midnight = time.Date(year, month, day, 0, 0, 0, 0, time.Local)
return
}

18
main.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"context"
"github.com/loveuer/nf/nft/log"
"os/signal"
"syscall"
"uauth/internal/cmd"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
defer cancel()
if err := cmd.Command.ExecuteContext(ctx); err != nil {
log.Error(err.Error())
}
}

11
model/authorization.go Normal file
View File

@ -0,0 +1,11 @@
package model
type AuthorizationRecord struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
UserId uint64 `json:"user_id" gorm:"index:unique_idx,unique;column:user_id"`
ClientId uint64 `json:"client_id" gorm:"index:unique_idx,unique;column:client_id"`
}

13
model/client.go Normal file
View File

@ -0,0 +1,13 @@
package model
type Client struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
ClientId string `json:"client_id" gorm:"unique;column:client_id;type:varchar(128)"`
ClientSecret string `json:"client_secret" gorm:"column:client_secret;type:varchar(32)"`
Name string `json:"name" gorm:"column:name;type:varchar(256)"`
Icon string `json:"icon" gorm:"column:icon"`
}

30
model/init.go Normal file
View File

@ -0,0 +1,30 @@
package model
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"uauth/internal/tool"
)
func Init(tx *gorm.DB) error {
var err error
if err = tx.AutoMigrate(
&User{},
&Client{},
&AuthorizationRecord{},
); err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).
Create(&User{Username: "admin", Nickname: "admin", Password: tool.NewPassword("Foobar123")}).Error; err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).
Create(&Client{ClientId: "test", ClientSecret: "Foobar123", Name: "测试", Icon: "https://picsum.photos/seed/loveuer/200"}).Error; err != nil {
return err
}
return nil
}

53
model/user.go Normal file
View File

@ -0,0 +1,53 @@
package model
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/loveuer/nf/nft/log"
"time"
"uauth/internal/opt"
)
type Status int64
type User struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"`
CreatedAt int64 `json:"created_at" gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;autoUpdateTime:milli"`
DeletedAt int64 `json:"deleted_at" gorm:"index;column:deleted_at;default:0"`
Username string `json:"username" gorm:"column:username;type:varchar(64);unique"`
Password string `json:"-" gorm:"column:password;type:varchar(256)"`
Status Status `json:"status" gorm:"column:status;default:0"`
Nickname string `json:"nickname" gorm:"column:nickname;type:varchar(64)"`
Avatar string `json:"avatar" gorm:"column:avatar;type:varchar(256)"`
Comment string `json:"comment" gorm:"column:comment"`
CreatedById uint64 `json:"created_by_id" gorm:"column:created_by_id"`
CreatedByName string `json:"created_by_name" gorm:"column:created_by_name;type:varchar(64)"`
LoginAt int64 `json:"login_at" gorm:"-"`
}
func (u *User) JwtEncode() (token string, err error) {
now := time.Now()
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{
"id": u.Id,
"username": u.Username,
"nickname": u.Nickname,
"status": u.Status,
"login_at": now.UnixMilli(),
})
if token, err = jwtToken.SignedString([]byte(opt.JwtTokenSecret)); err != nil {
err = fmt.Errorf("JwtEncode: jwt token signed secret err: %v", err)
log.Error(err.Error())
return "", nil
}
return
}

22
readme.md Normal file
View File

@ -0,0 +1,22 @@
# uauth
## run
- `go run . svc`
- `go run . client`
- `浏览器打开`[http://localhost:18080/login](http://localhost:18080/login)
## oauth2 authorization flow
- 1. 某某 系统/平台(比如: xx_platform) 的用户想要登录该 系统/平台, 并点击到登录页面
- 2. 用户发现该平台上有 `通过 {oauth2} 登录` 的按钮, 用户点击该按钮, 跳转到 `{oauth2}` 服务的登录页面如: `/oauth2/login`
> example: https://oauth2.com/login?client_id=...&scope=...&redirect_uri=...
> <b>需要注意, 这些参数都是必须的</b>
- 3. 跳转过来后, 有两种情况: 1. 用户没有在 {oauth2} 服务上登录过, 且当前的 token 依旧有效, 2. 用户在 {oauth2} 服务上未登录或者 token 已经过期
* 3.1 如果用户登录过且 token 有效, 则跳转到 `/oauth2/authorize` 进行授权
* 3.2 如果用户没有登录过, 或 token 已经失效, 则在登录成功后, 则跳转到 `/oauth2/authorize` 进行授权
- 4. 授权后, 会跳转到 redirect_uri 参数指定的地址, 并带上 code 和 state 参数
- 5. xx_platform 拿到 code 参数后, 向 {oauth2} 服务通过 code 请求 access_token