commit aefc004e3340332cd89f376d04f5104bff4bceca Author: loveuer Date: Wed Oct 23 17:46:15 2024 +0800 :tada: 完成基本的演示和样例 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..727e5fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +.vscode +.DS_Store +xtest +*.db +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b957ed --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a4b368 --- /dev/null +++ b/go.sum @@ -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= diff --git a/httptest/uauth/127.0.0.1 b/httptest/uauth/127.0.0.1 new file mode 100644 index 0000000..e69de29 diff --git a/httptest/uauth/auth req.bru b/httptest/uauth/auth req.bru new file mode 100644 index 0000000..6ca096c --- /dev/null +++ b/httptest/uauth/auth req.bru @@ -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 +} diff --git a/httptest/uauth/bruno.json b/httptest/uauth/bruno.json new file mode 100644 index 0000000..c46c40c --- /dev/null +++ b/httptest/uauth/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "uauth", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..683167c --- /dev/null +++ b/internal/client/client.go @@ -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) +} diff --git a/internal/client/login.html b/internal/client/login.html new file mode 100644 index 0000000..bb57e1d --- /dev/null +++ b/internal/client/login.html @@ -0,0 +1,52 @@ + + + + + + Client Login + + + +
+

这里是 xx 产品登录页面

+
+
+ + +
+ + + 使用 OAuth V2 账号登录 +
+
+ + \ No newline at end of file diff --git a/internal/cmd/client.go b/internal/cmd/client.go new file mode 100644 index 0000000..b21aeb3 --- /dev/null +++ b/internal/cmd/client.go @@ -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()) + }, + } +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..ad0a28a --- /dev/null +++ b/internal/cmd/cmd.go @@ -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(), + ) +} diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go new file mode 100644 index 0000000..7a5641a --- /dev/null +++ b/internal/cmd/serve.go @@ -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 +} diff --git a/internal/interfaces/database.go b/internal/interfaces/database.go new file mode 100644 index 0000000..af95046 --- /dev/null +++ b/internal/interfaces/database.go @@ -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 +} diff --git a/internal/interfaces/enum.go b/internal/interfaces/enum.go new file mode 100644 index 0000000..fd43fa6 --- /dev/null +++ b/internal/interfaces/enum.go @@ -0,0 +1,11 @@ +package interfaces + +type Enum interface { + Value() int64 + Code() string + Label() string + + MarshalJSON() ([]byte, error) + + All() []Enum +} diff --git a/internal/interfaces/logger.go b/internal/interfaces/logger.go new file mode 100644 index 0000000..8e75d58 --- /dev/null +++ b/internal/interfaces/logger.go @@ -0,0 +1,7 @@ +package interfaces + +type OpLogger interface { + Enum + Render(content map[string]any) (string, error) + Template() string +} diff --git a/internal/middleware/auth/auth.go b/internal/middleware/auth/auth.go new file mode 100644 index 0000000..57c6d45 --- /dev/null +++ b/internal/middleware/auth/auth.go @@ -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() + } +} diff --git a/internal/opt/config.go b/internal/opt/config.go new file mode 100644 index 0000000..f1b236b --- /dev/null +++ b/internal/opt/config.go @@ -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{} +) diff --git a/internal/opt/var.go b/internal/opt/var.go new file mode 100644 index 0000000..19dba84 --- /dev/null +++ b/internal/opt/var.go @@ -0,0 +1,6 @@ +package opt + +const ( + // 记得替换这个 + JwtTokenSecret = "2(v6UW3pBf1Miz^bY9u4rAUyv&dj8Kdz" +) diff --git a/internal/serve/handler/approve.go b/internal/serve/handler/approve.go new file mode 100644 index 0000000..c356df3 --- /dev/null +++ b/internal/serve/handler/approve.go @@ -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, + }) +} diff --git a/internal/serve/handler/authorize.go b/internal/serve/handler/authorize.go new file mode 100644 index 0000000..593bab4 --- /dev/null +++ b/internal/serve/handler/authorize.go @@ -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, + }) +} diff --git a/internal/serve/handler/login.go b/internal/serve/handler/login.go new file mode 100644 index 0000000..9e68da9 --- /dev/null +++ b/internal/serve/handler/login.go @@ -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, + }) +} diff --git a/internal/serve/handler/registry.go b/internal/serve/handler/registry.go new file mode 100644 index 0000000..97cd406 --- /dev/null +++ b/internal/serve/handler/registry.go @@ -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(` + + + + + + 注册成功 + + +

注册成功

+

快去试试吧

+ + +`) +} diff --git a/internal/serve/handler/serve_approve.html b/internal/serve/handler/serve_approve.html new file mode 100644 index 0000000..4c9144b --- /dev/null +++ b/internal/serve/handler/serve_approve.html @@ -0,0 +1,33 @@ + + + + + 授权成功 + + + + +授权成功, 正在跳转回原网页... +
+ +
+ + + diff --git a/internal/serve/handler/serve_authorize.html b/internal/serve/handler/serve_authorize.html new file mode 100644 index 0000000..6172c82 --- /dev/null +++ b/internal/serve/handler/serve_authorize.html @@ -0,0 +1,48 @@ + + + + + + Server Login + + + +
+

授权登录到 {{ .client_name }} 平台

+
+
+
+ +
+
+ {{ .user.username }} +
+
+
+
+
+ + + + +
+ +
+ + +
+
+
+ + diff --git a/internal/serve/handler/serve_login.html b/internal/serve/handler/serve_login.html new file mode 100644 index 0000000..a3fb18f --- /dev/null +++ b/internal/serve/handler/serve_login.html @@ -0,0 +1,89 @@ + + + + + + Server Login + + + +
+

欢迎来到 UAuth

+
+
+
+ +
+
UAuth
+
+
+ +
+
+
+ +
+
+ {{ .client_name }} +
+
+
+
+
+ + + + + + + +
+ + +
+
+ + diff --git a/internal/serve/handler/serve_login_success.html b/internal/serve/handler/serve_login_success.html new file mode 100644 index 0000000..09a1bbf --- /dev/null +++ b/internal/serve/handler/serve_login_success.html @@ -0,0 +1,41 @@ + + + + + 登录成功 + + + + +登录成功, 正在跳转... +
+ + + + + +
+ + + \ No newline at end of file diff --git a/internal/serve/handler/serve_registry.html b/internal/serve/handler/serve_registry.html new file mode 100644 index 0000000..eb1959f --- /dev/null +++ b/internal/serve/handler/serve_registry.html @@ -0,0 +1,70 @@ + + + + + + Server Login + + + +
+

欢迎注册 UAuth

+
+
+ + + + + +
+
+
+ + + diff --git a/internal/serve/handler/token.go b/internal/serve/handler/token.go new file mode 100644 index 0000000..a5fd473 --- /dev/null +++ b/internal/serve/handler/token.go @@ -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, + }) +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go new file mode 100644 index 0000000..cb3878c --- /dev/null +++ b/internal/serve/serve.go @@ -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) +} diff --git a/internal/store/cache/cache_lru.go b/internal/store/cache/cache_lru.go new file mode 100644 index 0000000..99665bf --- /dev/null +++ b/internal/store/cache/cache_lru.go @@ -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 +} diff --git a/internal/store/cache/cache_memory.go b/internal/store/cache/cache_memory.go new file mode 100644 index 0000000..fb9e508 --- /dev/null +++ b/internal/store/cache/cache_memory.go @@ -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 +} diff --git a/internal/store/cache/cache_redis.go b/internal/store/cache/cache_redis.go new file mode 100644 index 0000000..bbf7b5d --- /dev/null +++ b/internal/store/cache/cache_redis.go @@ -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() +} diff --git a/internal/store/cache/client.go b/internal/store/cache/client.go new file mode 100644 index 0000000..bd1b13f --- /dev/null +++ b/internal/store/cache/client.go @@ -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 +} diff --git a/internal/store/cache/error.go b/internal/store/cache/error.go new file mode 100644 index 0000000..f0798b5 --- /dev/null +++ b/internal/store/cache/error.go @@ -0,0 +1,7 @@ +package cache + +import "errors" + +var ( + ErrorKeyNotFound = errors.New("key not found") +) diff --git a/internal/store/cache/init.go b/internal/store/cache/init.go new file mode 100644 index 0000000..fdedc76 --- /dev/null +++ b/internal/store/cache/init.go @@ -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 +} diff --git a/internal/store/cache/scan.go b/internal/store/cache/scan.go new file mode 100644 index 0000000..c65d267 --- /dev/null +++ b/internal/store/cache/scan.go @@ -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} +} diff --git a/internal/store/db/client.go b/internal/store/db/client.go new file mode 100644 index 0000000..9759a23 --- /dev/null +++ b/internal/store/db/client.go @@ -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() +} diff --git a/internal/store/db/db_test.go b/internal/store/db/db_test.go new file mode 100644 index 0000000..8cce6fa --- /dev/null +++ b/internal/store/db/db_test.go @@ -0,0 +1,9 @@ +package db + +import ( + "testing" +) + +func TestOpen(t *testing.T) { + +} diff --git a/internal/store/db/init.go b/internal/store/db/init.go new file mode 100644 index 0000000..d137f13 --- /dev/null +++ b/internal/store/db/init.go @@ -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 +} diff --git a/internal/tool/cert.go b/internal/tool/cert.go new file mode 100644 index 0000000..029cfc7 --- /dev/null +++ b/internal/tool/cert.go @@ -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 +} diff --git a/internal/tool/ctx.go b/internal/tool/ctx.go new file mode 100644 index 0000000..82242a3 --- /dev/null +++ b/internal/tool/ctx.go @@ -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 +} diff --git a/internal/tool/file.go b/internal/tool/file.go new file mode 100644 index 0000000..cefa36d --- /dev/null +++ b/internal/tool/file.go @@ -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 +} diff --git a/internal/tool/human.go b/internal/tool/human.go new file mode 100644 index 0000000..2c7ce71 --- /dev/null +++ b/internal/tool/human.go @@ -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) +} diff --git a/internal/tool/must.go b/internal/tool/must.go new file mode 100644 index 0000000..0615f8d --- /dev/null +++ b/internal/tool/must.go @@ -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()) + } + } +} diff --git a/internal/tool/password.go b/internal/tool/password.go new file mode 100644 index 0000000..c2d1a17 --- /dev/null +++ b/internal/tool/password.go @@ -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 +} diff --git a/internal/tool/password_test.go b/internal/tool/password_test.go new file mode 100644 index 0000000..aabd667 --- /dev/null +++ b/internal/tool/password_test.go @@ -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) +} diff --git a/internal/tool/random.go b/internal/tool/random.go new file mode 100644 index 0000000..266cb4c --- /dev/null +++ b/internal/tool/random.go @@ -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) +} diff --git a/internal/tool/slice.go b/internal/tool/slice.go new file mode 100644 index 0000000..05a7dd5 --- /dev/null +++ b/internal/tool/slice.go @@ -0,0 +1,5 @@ +package tool + +func Bulk[T any](slice []T, size int) { + // todo +} diff --git a/internal/tool/slice_test.go b/internal/tool/slice_test.go new file mode 100644 index 0000000..05b1676 --- /dev/null +++ b/internal/tool/slice_test.go @@ -0,0 +1 @@ +package tool diff --git a/internal/tool/table.go b/internal/tool/table.go new file mode 100644 index 0000000..ffaaf31 --- /dev/null +++ b/internal/tool/table.go @@ -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}) + } +} diff --git a/internal/tool/time.go b/internal/tool/time.go new file mode 100644 index 0000000..a193f20 --- /dev/null +++ b/internal/tool/time.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ec904fa --- /dev/null +++ b/main.go @@ -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()) + } +} diff --git a/model/authorization.go b/model/authorization.go new file mode 100644 index 0000000..3c82d2f --- /dev/null +++ b/model/authorization.go @@ -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"` +} diff --git a/model/client.go b/model/client.go new file mode 100644 index 0000000..613aa8f --- /dev/null +++ b/model/client.go @@ -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"` +} diff --git a/model/init.go b/model/init.go new file mode 100644 index 0000000..f86083c --- /dev/null +++ b/model/init.go @@ -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 +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..26716c6 --- /dev/null +++ b/model/user.go @@ -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 +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6f20838 --- /dev/null +++ b/readme.md @@ -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=... + + > 需要注意, 这些参数都是必须的 + +- 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