├── .github └── workflows │ ├── deploy.yml │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config └── nekocas.sample.toml ├── go.mod ├── go.sum ├── internal ├── conf │ └── config.go ├── context │ ├── auth.go │ └── context.go ├── db │ ├── ldap.go │ ├── mysql.go │ ├── redis.go │ ├── service.go │ ├── setting.go │ ├── ticket.go │ └── user.go ├── filesystem │ └── fs.go ├── form │ ├── account.go │ ├── form.go │ ├── service.go │ └── site.go ├── helper │ └── helper.go ├── mail │ └── mail.go ├── spec │ ├── v1 │ │ └── validate.go │ └── v2 │ │ ├── type.go │ │ └── validate.go └── web │ ├── account │ ├── activation.go │ ├── dashboard.go │ ├── login.go │ ├── logout.go │ ├── lost_password.go │ ├── profile.go │ └── register.go │ ├── manager │ ├── service.go │ ├── setting.go │ └── user.go │ ├── middleware │ └── middleware.go │ ├── router.go │ └── template │ └── template.go ├── nekocas.go ├── public ├── fs.go └── static │ └── NekoWheel.png └── templates ├── 404.tmpl ├── activate.tmpl ├── dashboard.tmpl ├── error.tmpl ├── fs.go ├── layouts ├── alert.tmpl ├── bar.tmpl ├── footer.tmpl ├── header.tmpl └── privacy.tmpl ├── login.tmpl ├── logout.tmpl ├── lost_password.tmpl ├── mail ├── activate.tmpl └── reset_password.tmpl ├── manage ├── delete_service.tmpl ├── edit_service.tmpl ├── new_service.tmpl ├── services.tmpl ├── site.tmpl └── users.tmpl ├── privacy.tmpl ├── profile.tmpl ├── profile_edit.tmpl ├── register.tmpl └── reset_password.tmpl /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build Binary 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 1.16 14 | uses: actions/setup-go@v1 15 | with: 16 | go-version: 1.16 17 | id: go 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v1 21 | 22 | - name: Get dependencies 23 | run: | 24 | go mod tidy 25 | - name: Build 26 | run: | 27 | CGO_ENABLED=0 go build -v -ldflags "-w -s -extldflags '-static' -X 'github.com/NekoWheel/NekoCAS/internal/conf.CommitSHA=$GITHUB_SHA'" -o NekoCAS . 28 | pwd 29 | - name: Archive production artifacts 30 | uses: actions/upload-artifact@v1 31 | with: 32 | name: NekoCAS 33 | path: /home/runner/work/NekoCAS/NekoCAS 34 | 35 | dockerfile: 36 | name: Build Image 37 | runs-on: ubuntu-latest 38 | needs: build 39 | steps: 40 | - name: Get artifacts 41 | uses: actions/download-artifact@master 42 | with: 43 | name: NekoCAS 44 | path: /home/runner/work/NekoCAS/NekoCAS 45 | 46 | - id: create_docker_tags 47 | run: | 48 | echo "::set-output name=tags::latest,$(git tag -l --sort=-v:refname | head -1 | cut -c 2-)" 49 | - name: Build & Publish to Registry 50 | uses: elgohr/Publish-Docker-Github-Action@master 51 | with: 52 | name: ${{ secrets.DOCKER_NAME }} 53 | username: ${{ secrets.DOCKER_USERNAME }} 54 | password: ${{ secrets.DOCKER_PASSWORD }} 55 | registry: ${{ secrets.DOCKER_REGISTRY }} 56 | tags: ${{ steps.create_docker_tags.outputs.tags }} 57 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [ master ] 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - '.github/workflows/go.yml' 9 | pull_request: 10 | paths: 11 | - '**.go' 12 | - 'go.mod' 13 | - '.github/workflows/go.yml' 14 | env: 15 | GOPROXY: "https://proxy.golang.org" 16 | 17 | jobs: 18 | lint: 19 | name: Lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | - name: Run golangci-lint 25 | uses: golangci/golangci-lint-action@v2.4.0 26 | with: 27 | version: v1.37.0 28 | args: --timeout=30m -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | config/nekocas.toml -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 4 | RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 5 | RUN echo 'Asia/Shanghai' > /etc/timezone 6 | 7 | RUN mkdir /home/app/ 8 | ADD NekoCAS /home/app/ 9 | 10 | RUN chmod 655 /home/app/NekoCAS 11 | ENV MACARON_ENV production 12 | 13 | WORKDIR /home/app 14 | ENTRYPOINT ["./NekoCAS"] 15 | EXPOSE 8080 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 John Wu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 | 中央认证服务 / Central Authentication Service 4 | 5 | ## 搭建开发环境 6 | 7 | NekoCAS 需要以下依赖: 8 | - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) (v1.8.3 or higher) 9 | - [Go](https://golang.org/doc/install) (v1.14 or higher) 10 | 11 | ### 获取代码 12 | 13 | ``` 14 | git clone --depth 1 https://github.com/NekoWheel/NekoCAS 15 | ``` 16 | 17 | ### 安装项目依赖 18 | 19 | ```bash 20 | go mod tidy 21 | ``` 22 | 23 | ### 项目说明 24 | NekoCAS 为 NekoWheel 的中央认证服务,注意其**只实现**了 CAS Protocol 中的如下接口: 25 | * `/login` v1 26 | * `/logout` v1 27 | * `/validate` v1 28 | * `/serviceValidate` v2 29 | -------------------------------------------------------------------------------- /config/nekocas.sample.toml: -------------------------------------------------------------------------------- 1 | [site] 2 | name = "NekoCAS" 3 | port = 8000 4 | icp = "" 5 | security_key = "" 6 | csrf_key = "" 7 | 8 | [mysql] 9 | user = "" 10 | password = "" 11 | addr = "tcp(localhost:3306)" 12 | name = "" 13 | 14 | [redis] 15 | addr = "127.0.0.1:6379" 16 | password = "" 17 | 18 | [mail] 19 | account = "" 20 | password = "" 21 | smtp = "" 22 | port = 465 23 | 24 | [ldap] 25 | enabled = false 26 | url= "ldap://127.0.0.1" 27 | bind_dn = "cn=readonly,dc=example,dc=com" 28 | bind_password = "password" 29 | user_filter = "(email=%s)" 30 | base_dn = "dc=example,dc=com" 31 | sync_interval = "24h" 32 | 33 | [ldap.mapping] 34 | nickname = "uid" 35 | email = "mail" 36 | avatar = "avatar" 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/NekoWheel/NekoCAS 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/fatih/color v1.10.0 // indirect 8 | github.com/go-ldap/ldap/v3 v3.4.6 // indirect 9 | github.com/go-macaron/binding v1.1.1 10 | github.com/go-macaron/cache v0.0.0-20200329073519-53bb48172687 11 | github.com/go-macaron/csrf v0.0.0-20200329073418-5d38f39de352 12 | github.com/go-macaron/session v1.0.0 13 | github.com/go-redis/redis/v7 v7.2.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/smartystreets/goconvey v1.6.4 // indirect 16 | github.com/thanhpk/randstr v1.0.4 17 | github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e 18 | golang.org/x/crypto v0.13.0 19 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 20 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 21 | gopkg.in/macaron.v1 v1.3.9 22 | gorm.io/driver/mysql v1.0.3 23 | gorm.io/gorm v1.20.5 24 | unknwon.dev/clog/v2 v2.1.2 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= 2 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 3 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 6 | github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= 7 | github.com/couchbase/gomemcached v0.0.0-20190515232915-c4b4ca0eb21d/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c= 8 | github.com/couchbase/goutils v0.0.0-20190315194238-f9d42b11473b/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs= 9 | github.com/couchbaselabs/go-couchbase v0.0.0-20190708161019-23e7ca2ce2b7/go.mod h1:mby/05p8HE5yHEAKiIH/555NoblMs7PtW6NrYshDruc= 10 | github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= 15 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 16 | github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= 17 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 18 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 19 | github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= 20 | github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 21 | github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= 22 | github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= 23 | github.com/go-macaron/binding v1.1.1 h1:agcYYn3FDj5YQ43CkVRMZ74M2BdqP/X1ut5+hVi8anI= 24 | github.com/go-macaron/binding v1.1.1/go.mod h1:dJU/AtPKG0gUiFra1K5TTGduFGMNxMvfJzV/zmXwyGM= 25 | github.com/go-macaron/cache v0.0.0-20200329073519-53bb48172687 h1:G4yk9vJc6amLZu9rEEafr1FZJHytZ2IKHUmuPUTb2zc= 26 | github.com/go-macaron/cache v0.0.0-20200329073519-53bb48172687/go.mod h1:wZk2O+x+Aqyx1QuP3fcRHtPVwxYWNNEzaAZHxb9GGkA= 27 | github.com/go-macaron/csrf v0.0.0-20200329073418-5d38f39de352 h1:fajW0xpyt4ACXMQMyMqAHbwvolPUxO8LQdQcr7/6c68= 28 | github.com/go-macaron/csrf v0.0.0-20200329073418-5d38f39de352/go.mod h1:FX53Xq0NNlUj0E5in5J8Dq5nrbdK3ZyDIy6y5VWOiUo= 29 | github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI= 30 | github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw= 31 | github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659/go.mod h1:tLd0QEudXocQckwcpCq5pCuTCuYc24I0bRJDuRe9OuQ= 32 | github.com/go-macaron/session v1.0.0 h1:ixsWNPxg038ZuhaMBN64sqLRaueRwDUSbF6q+30WJic= 33 | github.com/go-macaron/session v1.0.0/go.mod h1:tLd0QEudXocQckwcpCq5pCuTCuYc24I0bRJDuRe9OuQ= 34 | github.com/go-redis/redis/v7 v7.2.0 h1:CrCexy/jYWZjW0AyVoHlcJUeZN19VWlbepTh1Vq6dJs= 35 | github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= 36 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 37 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 38 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 39 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 40 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 41 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 42 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 43 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 44 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 45 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 46 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 47 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 48 | github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= 49 | github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 50 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 51 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 52 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 53 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 54 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 55 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 56 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 57 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 58 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 59 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 60 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 61 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 62 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 63 | github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 64 | github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ= 65 | github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0= 66 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 67 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 68 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 69 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 70 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 71 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 72 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 73 | github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 74 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 75 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 76 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 77 | github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= 78 | github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 79 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 80 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 81 | github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= 82 | github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 83 | github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= 84 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 85 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 86 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 87 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 88 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 89 | github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw= 90 | github.com/siddontang/go-snappy v0.0.0-20140704025258-d8f7bb82a96d/go.mod h1:vq0tzqLRu6TS7Id0wMo2N5QzJoKedVeovOpHjnykSzY= 91 | github.com/siddontang/ledisdb v0.0.0-20190202134119-8ceb77e66a92/go.mod h1:mF1DpOSOUiJRMR+FDqaqu3EBqrybQtrDDszLUZ6oxPg= 92 | github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA= 93 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 94 | github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 95 | github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= 96 | github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= 97 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 98 | github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 99 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 100 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 101 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 103 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 104 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 105 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 106 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 107 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 108 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 109 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 110 | github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= 111 | github.com/thanhpk/randstr v1.0.4 h1:IN78qu/bR+My+gHCvMEXhR/i5oriVHcTB/BJJIRTsNo= 112 | github.com/thanhpk/randstr v1.0.4/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= 113 | github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e h1:GSGeB9EAKY2spCABz6xOX5DbxZEXolK+nBSvmsQwRjM= 114 | github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= 115 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 116 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 117 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 118 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 119 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= 120 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 121 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 122 | golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= 123 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 124 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 125 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 126 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 127 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 128 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 129 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 130 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 131 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 132 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 133 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= 134 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 135 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 136 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 137 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= 138 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 139 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 140 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 141 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 142 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 143 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 144 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 145 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 146 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 147 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 148 | golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= 155 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 156 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 160 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 161 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 163 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 164 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 165 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 166 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 167 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 168 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 169 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 170 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 171 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 172 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 173 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 174 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 175 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 176 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 177 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 178 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 179 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 180 | golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= 181 | golang.org/x/tools v0.0.0-20190805222050-c5a2fd39b72a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= 182 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 183 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 184 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 185 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 186 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 187 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 188 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 189 | gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e/go.mod h1:xsQCaysVCudhrYTfzYWe577fCe7Ceci+6qjO2Rdc0Z4= 190 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 191 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 192 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 193 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 194 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 195 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 196 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 197 | gopkg.in/ini.v1 v1.46.0 h1:VeDZbLYGaupuvIrsYCEOe/L/2Pcs5n7hdO1ZTjporag= 198 | gopkg.in/ini.v1 v1.46.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 199 | gopkg.in/macaron.v1 v1.3.4/go.mod h1:/RoHTdC8ALpyJ3+QR36mKjwnT1F1dyYtsGM9Ate6ZFI= 200 | gopkg.in/macaron.v1 v1.3.5/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= 201 | gopkg.in/macaron.v1 v1.3.9 h1:Dw+DDRYdXgQyEsPlfAfKz+UA5qVUrH3KPD7JhmZ9MFc= 202 | gopkg.in/macaron.v1 v1.3.9/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= 203 | gopkg.in/redis.v2 v2.3.2/go.mod h1:4wl9PJ/CqzeHk3LVq1hNLHH8krm3+AXEgut4jVc++LU= 204 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 205 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 206 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 207 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 208 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 209 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 210 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 211 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 212 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 213 | gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= 214 | gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= 215 | gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 216 | gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g= 217 | gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 218 | unknwon.dev/clog/v2 v2.1.2 h1:+jwPPp10UtOPunFtviUmXF01Abf6q7p5GEy4jluLl8o= 219 | unknwon.dev/clog/v2 v2.1.2/go.mod h1:zvUlyibDHI4mykYdWyWje2G9nF/nBzfDOqRo2my4mWc= 220 | -------------------------------------------------------------------------------- /internal/conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // CommitSHA 在编译时注入,为当前 Git Commit 哈希值。 9 | var CommitSHA = "debug" 10 | 11 | var Site SiteSegment 12 | 13 | type SiteSegment struct { 14 | Name string `toml:"name"` 15 | BaseURL string `toml:"base_url"` 16 | Port int `toml:"port"` 17 | ICP string `toml:"icp"` 18 | SecurityKey string `toml:"security_key"` 19 | CSRFKey string `toml:"csrf_key"` 20 | } 21 | 22 | var MySQL MySQLSegment 23 | 24 | type MySQLSegment struct { 25 | User string `toml:"user"` 26 | Password string `toml:"password"` 27 | Addr string `toml:"addr"` 28 | Name string `toml:"name"` 29 | } 30 | 31 | var Redis RedisSegment 32 | 33 | type RedisSegment struct { 34 | Addr string `toml:"addr"` 35 | Password string `toml:"password"` 36 | } 37 | 38 | var Mail MailSegment 39 | 40 | type MailSegment struct { 41 | Account string `toml:"account"` 42 | Password string `toml:"password"` 43 | SMTP string `toml:"smtp"` 44 | Port int `toml:"port"` 45 | } 46 | 47 | var Ldap LdapSegment 48 | 49 | type LdapSegment struct { 50 | Enabled bool `toml:"enabled"` 51 | URL string `toml:"url"` 52 | BindDN string `toml:"bind_dn"` 53 | BindPassword string `toml:"bind_password"` 54 | UserFilter string `toml:"user_filter"` 55 | BaseDN string `toml:"base_dn"` 56 | SyncInterval string `toml:"sync_interval"` 57 | Mapping struct { 58 | Nickname string `toml:"nickname"` 59 | Email string `toml:"email"` 60 | Avatar string `toml:"avatar"` 61 | } `toml:"mapping"` 62 | } 63 | 64 | // Load 从配置文件中加载配置。 65 | func Load() error { 66 | var config struct { 67 | Site SiteSegment `toml:"site"` 68 | MySQL MySQLSegment `toml:"mysql"` 69 | Redis RedisSegment `toml:"redis"` 70 | Mail MailSegment `toml:"mail"` 71 | Ldap LdapSegment `toml:"ldap"` 72 | } 73 | 74 | _, err := toml.DecodeFile("./config/nekocas.toml", &config) 75 | if err != nil { 76 | return errors.Wrap(err, "decode config file") 77 | } 78 | 79 | Site = config.Site 80 | MySQL = config.MySQL 81 | Redis = config.Redis 82 | Mail = config.Mail 83 | Ldap = config.Ldap 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/context/auth.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/go-macaron/session" 7 | "gopkg.in/macaron.v1" 8 | 9 | "github.com/NekoWheel/NekoCAS/internal/db" 10 | ) 11 | 12 | type ToggleOptions struct { 13 | SignInRequired bool 14 | SignOutRequired bool 15 | AdminRequired bool 16 | } 17 | 18 | func Toggle(options *ToggleOptions) macaron.Handler { 19 | return func(c *Context) { 20 | // 已登录用户尝试访问未登录页面 21 | if options.SignOutRequired && c.IsLogged && c.Req.RequestURI != "/" { 22 | c.Redirect("/") 23 | return 24 | } 25 | 26 | // 未授权访问 27 | if options.SignInRequired { 28 | if !c.IsLogged { 29 | c.SetCookie("redirect_to", url.QueryEscape(c.Req.RequestURI)) 30 | c.Redirect("/login") 31 | return 32 | } 33 | } 34 | 35 | // 管理员访问 36 | if options.AdminRequired { 37 | if !c.User.IsAdmin { 38 | c.Redirect("/") 39 | return 40 | } 41 | } 42 | } 43 | } 44 | 45 | func authenticatedUser(sess session.Store) *db.User { 46 | id := sess.Get("uid") 47 | if id == nil { 48 | return nil 49 | } 50 | uid := id.(uint) 51 | 52 | if uid == 0 { 53 | return nil 54 | } 55 | return db.MustGetUserByID(uid) 56 | } 57 | -------------------------------------------------------------------------------- /internal/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-macaron/cache" 7 | "github.com/go-macaron/csrf" 8 | "github.com/go-macaron/session" 9 | "gopkg.in/macaron.v1" 10 | log "unknwon.dev/clog/v2" 11 | 12 | "github.com/NekoWheel/NekoCAS/internal/conf" 13 | "github.com/NekoWheel/NekoCAS/internal/db" 14 | "github.com/NekoWheel/NekoCAS/internal/form" 15 | "github.com/NekoWheel/NekoCAS/internal/helper" 16 | "github.com/NekoWheel/NekoCAS/internal/web/template" 17 | ) 18 | 19 | // Context 为请求上下文。 20 | type Context struct { 21 | *macaron.Context 22 | Cache cache.Cache 23 | csrf csrf.CSRF 24 | Flash *session.Flash 25 | Session session.Store 26 | Setting *setting 27 | 28 | User *db.User 29 | IsLogged bool 30 | Service *db.Service 31 | ServiceURL string 32 | } 33 | 34 | type setting struct { 35 | OpenRegister string 36 | SiteLogo string 37 | MailWhitelist string 38 | Privacy string 39 | } 40 | 41 | // Success 返回模板,状态码 200。 42 | func (c *Context) Success(name string) { 43 | c.HTML(http.StatusOK, name) 44 | } 45 | 46 | // Error 返回模板错误页。 47 | func (c *Context) Error(err error) { 48 | c.Data["ErrorMsg"] = err 49 | c.HTML(http.StatusOK, "error") 50 | } 51 | 52 | // RenderWithErr 返回表单报错。 53 | func (c *Context) RenderWithErr(msg, tpl string, f interface{}) { 54 | if f != nil { 55 | form.Assign(f, c.Data) 56 | } 57 | c.Flash.ErrorMsg = msg 58 | c.Data["Flash"] = c.Flash 59 | c.HTML(http.StatusOK, tpl) 60 | } 61 | 62 | // HasError 返回表单验证是否有错误。 63 | func (c *Context) HasError() bool { 64 | hasErr, ok := c.Data["HasError"] 65 | if !ok { 66 | return false 67 | } 68 | c.Flash.ErrorMsg = c.Data["ErrorMsg"].(string) 69 | c.Data["Flash"] = c.Flash 70 | return hasErr.(bool) 71 | } 72 | 73 | // Contexter 初始化一个请求上下文实例。 74 | func Contexter() macaron.Handler { 75 | return func(ctx *macaron.Context, sess session.Store, f *session.Flash, x csrf.CSRF, cache cache.Cache) { 76 | c := &Context{ 77 | Context: ctx, 78 | Cache: cache, 79 | csrf: x, 80 | Flash: f, 81 | Session: sess, 82 | Setting: &setting{}, 83 | } 84 | 85 | // 获取登录用户信息 86 | c.User = authenticatedUser(c.Session) 87 | 88 | if c.User != nil { 89 | c.IsLogged = true 90 | c.Data["LoggedUser"] = c.User 91 | c.Data["IsAdmin"] = c.User.IsAdmin 92 | 93 | // 检查用户账号是否已激活 94 | if !c.User.IsActive && 95 | ctx.Req.URL.Path != "/activate" && 96 | ctx.Req.URL.Path != "/activate_code" && 97 | ctx.Req.URL.Path != "/logout" { // 允许未激活用户登出 98 | c.Redirect("/activate") 99 | return 100 | } 101 | // 账号已激活 102 | if c.User.IsActive { 103 | if ctx.Req.URL.Path == "/activate" || ctx.Req.URL.Path == "/activate_code" { 104 | c.Redirect("/") 105 | } 106 | } 107 | } 108 | 109 | // 站点设置 110 | c.Setting = &setting{ 111 | OpenRegister: db.MustGetSetting("open_register", "on"), 112 | SiteLogo: db.MustGetSetting("site_logo", "https://cas.n3ko.co/static/NekoWheel.png"), 113 | MailWhitelist: db.MustGetSetting("mail_whitelist"), 114 | Privacy: db.MustGetSetting("privacy"), 115 | } 116 | c.Data["Setting"] = c.Setting 117 | 118 | // 后台菜单 119 | c.Data["Tab"] = c.Flash.Get("Tab") 120 | 121 | c.Data["SiteName"] = conf.Site.Name 122 | c.Data["CommitSha"] = helper.Substr(conf.CommitSHA, 0, 8) 123 | c.Data["CommitLink"] = "https://github.com/NekoWheel/NekoCAS/commit/" + conf.CommitSHA 124 | c.Data["CSRFToken"] = x.GetToken() 125 | c.Data["CSRFTokenHTML"] = template.Safe(``) 126 | log.Trace("Session ID: %s", sess.ID()) 127 | log.Trace("CSRF Token: %v", c.Data["CSRFToken"]) 128 | 129 | ctx.Map(c) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /internal/db/ldap.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/NekoWheel/NekoCAS/internal/conf" 10 | "github.com/go-ldap/ldap/v3" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | func ldapDialAndBind() (*ldap.Conn, error) { 15 | c, err := ldap.DialURL(conf.Ldap.URL) 16 | if err != nil { 17 | return nil, errors.Wrap(err, "connect ldap") 18 | } 19 | 20 | err = c.Bind(conf.Ldap.BindDN, conf.Ldap.BindPassword) 21 | if err != nil { 22 | return nil, errors.Wrap(err, "auth ldap") 23 | } 24 | 25 | return c, nil 26 | } 27 | 28 | func AutoSyncLdap() { 29 | dur, err := time.ParseDuration(conf.Ldap.SyncInterval) 30 | if err != nil { 31 | log.Println("parse time duration error, ldap sync is not working") 32 | } 33 | 34 | if err := syncLdap(); err != nil { 35 | log.Println("sync ldap error:", err) 36 | } 37 | t := time.NewTicker(dur) 38 | for range t.C { 39 | if err := syncLdap(); err != nil { 40 | log.Println("sync ldap error:", err) 41 | } 42 | } 43 | } 44 | 45 | func syncLdap() error { 46 | c, err := ldapDialAndBind() 47 | if err != nil { 48 | return errors.Wrap(err, "sync ldap") 49 | } 50 | 51 | userFilter := fmt.Sprintf(conf.Ldap.UserFilter, "*") 52 | req := ldap.NewSearchRequest(conf.Ldap.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 53 | 0, 0, false, userFilter, 54 | []string{conf.Ldap.Mapping.Nickname, conf.Ldap.Mapping.Email, conf.Ldap.Mapping.Avatar}, nil) 55 | res, err := c.Search(req) 56 | if err != nil { 57 | return errors.Wrap(err, "search ldap") 58 | } 59 | 60 | for _, e := range res.Entries { 61 | email := strings.ToLower(e.GetAttributeValue(conf.Ldap.Mapping.Email)) 62 | nickname := e.GetAttributeValue(conf.Ldap.Mapping.Nickname) 63 | if !IsEmailUsed(email) && !IsNickNameUsed(nickname) { 64 | user := &User{ 65 | NickName: nickname, 66 | Email: email, 67 | IsLdap: true, 68 | IsActive: true, 69 | } 70 | if err := CreateUser(user); err != nil { 71 | log.Println("create user error:", err) 72 | } 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | func ldapAuthenticate(email string, password string) (bool, error) { 79 | c, err := ldapDialAndBind() 80 | if err != nil { 81 | return false, nil 82 | } 83 | 84 | filter, err := sanitizedUserQuery(email) 85 | if err != nil { 86 | return false, errors.Wrap(err, "sanitize input email") 87 | } 88 | 89 | req := ldap.NewSearchRequest(conf.Ldap.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, filter, nil, nil) 90 | res, err := c.Search(req) 91 | if err != nil { 92 | return false, errors.Wrap(err, "serach ldap") 93 | } 94 | if len(res.Entries) != 1 { 95 | return false, errors.Wrap(err, "entry not unique or not existed") 96 | } 97 | 98 | userDN := res.Entries[0].DN 99 | if err := c.Bind(userDN, password); err != nil { 100 | if ldap.IsErrorWithCode(err, ldap.LDAPResultAuthorizationDenied) { 101 | return false, nil 102 | } 103 | return false, errors.Wrap(err, "bind DN") 104 | } 105 | 106 | return true, nil 107 | } 108 | 109 | func sanitizedUserQuery(email string) (string, error) { 110 | // See http://tools.ietf.org/search/rfc4515 111 | badCharacters := "\x00()*\\" 112 | if strings.ContainsAny(email, badCharacters) { 113 | return "", fmt.Errorf("'%s' contains invalid query characters. Aborting.", email) 114 | } 115 | 116 | return fmt.Sprintf(conf.Ldap.UserFilter, email), nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/db/mysql.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "gorm.io/driver/mysql" 8 | "gorm.io/gorm" 9 | 10 | "github.com/NekoWheel/NekoCAS/internal/conf" 11 | ) 12 | 13 | var db *gorm.DB 14 | 15 | func ConnDB() error { 16 | dsn := fmt.Sprintf("%s:%s@%s/%s?charset=utf8mb4&parseTime=True&loc=Local", 17 | conf.MySQL.User, 18 | conf.MySQL.Password, 19 | conf.MySQL.Addr, 20 | conf.MySQL.Name, 21 | ) 22 | 23 | var err error 24 | db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) 25 | if err != nil { 26 | return errors.Wrap(err, "connect database") 27 | } 28 | 29 | err = db.AutoMigrate(&User{}, &Service{}, &Setting{}) 30 | if err != nil { 31 | return errors.Wrap(err, "auto migrate") 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/db/redis.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/go-redis/redis/v7" 5 | "github.com/pkg/errors" 6 | 7 | "github.com/NekoWheel/NekoCAS/internal/conf" 8 | ) 9 | 10 | var red *redis.Client 11 | 12 | func ConnRedis() error { 13 | red = redis.NewClient(&redis.Options{ 14 | Addr: conf.Redis.Addr, 15 | Password: conf.Redis.Password, 16 | DB: 0, 17 | }) 18 | 19 | cb := red.Ping() 20 | if err := cb.Err(); err != nil { 21 | return errors.Wrap(err, "ping") 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/db/service.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/pkg/errors" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // Service 为接入的服务。 11 | type Service struct { 12 | gorm.Model 13 | 14 | Name string 15 | Avatar string // 服务 Logo 16 | Domain string // 白名单域名 17 | Ban bool // 是否封禁 18 | } 19 | 20 | var ErrServiceExists = errors.New("服务已存在") 21 | 22 | // CreateService 新建一个新的服务。 23 | func CreateService(s *Service) error { 24 | isExist := IsServiceExist(s.Name) 25 | if isExist { 26 | return ErrServiceExists 27 | } 28 | 29 | if err := db.Create(s).Error; err != nil { 30 | return errors.Wrap(err, "添加新服务") 31 | } 32 | return nil 33 | } 34 | 35 | var ErrorServiceNotFound = errors.New("服务不存在") 36 | 37 | // UpdateService 更新一个服务。 38 | func UpdateService(s *Service) error { 39 | _, err := GetServiceByID(s.ID) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if err := db.Model(&Service{}).Where("id = ?", s.ID).Updates(map[string]interface{}{ 45 | "Name": s.Name, 46 | "Avatar": s.Avatar, 47 | "Domain": s.Domain, 48 | "Ban": s.Ban, 49 | }).Error; err != nil { 50 | return errors.Wrap(err, "更新服务") 51 | } 52 | return nil 53 | } 54 | 55 | // ServiceByURL 通过 ServiceURL 查找对应的服务。 56 | func ServiceByURL(u string) (*Service, error) { 57 | serviceURL, err := url.ParseRequestURI(u) 58 | if err != nil || serviceURL.Hostname() == "" { 59 | return nil, errors.New("参数无效") 60 | } 61 | 62 | if serviceURL.Scheme != "https" && serviceURL.Scheme != "http" { 63 | return nil, errors.New("不支持的协议") 64 | } 65 | 66 | var service Service 67 | if err := db.Model(&Service{}).Where("domain = ? and ban = ?", serviceURL.Hostname(), false).First(&service).Error; err != nil { 68 | return nil, errors.New("域名不在白名单内") 69 | } 70 | return &service, nil 71 | } 72 | 73 | // GetServiceByID 根据对应的 ServiceID 查找对应的服务。 74 | func GetServiceByID(id uint) (*Service, error) { 75 | var s Service 76 | if err := db.Model(&Service{}).Where(&User{ 77 | Model: gorm.Model{ 78 | ID: id, 79 | }, 80 | }).First(&s).Error; err != nil { 81 | if err == gorm.ErrRecordNotFound { 82 | return nil, ErrorServiceNotFound 83 | } 84 | return nil, err 85 | } 86 | return &s, nil 87 | } 88 | 89 | // DeleteService 根据指定 Service ID 删除对应的服务。 90 | func DeleteService(id uint) error { 91 | return db.Model(&Service{}).Where("id = ?", id).Delete(&Service{}).Error 92 | } 93 | 94 | // GetServices 批量获取服务。 95 | // options[0] offset 96 | // options[1] limit 97 | func GetServices(options ...int) ([]*Service, error) { 98 | var services []*Service 99 | 100 | if len(options) == 0 { 101 | if err := db.Model(&Service{}).Find(&services).Error; err != nil { 102 | return nil, err 103 | } 104 | } else { 105 | offset := 0 106 | if len(options) > 1 && options[0] > 0 { 107 | offset = options[0] 108 | } 109 | 110 | limit := 0 111 | if len(options) == 2 && options[1] > 0 { 112 | limit = options[1] 113 | } 114 | 115 | if err := db.Model(&Service{}).Offset(offset).Limit(limit).Find(&services).Error; err != nil { 116 | return nil, err 117 | } 118 | } 119 | 120 | return services, nil 121 | } 122 | 123 | // CountServices 返回服务的总数。 124 | func CountServices() int64 { 125 | var count int64 126 | db.Model(&Service{}).Count(&count) 127 | return count 128 | } 129 | 130 | // IsServiceExist 检查服务名是否重复。 131 | func IsServiceExist(name string) bool { 132 | if name == "" { 133 | return false 134 | } 135 | 136 | var s Service 137 | err := db.Model(&Service{}).Where(&Service{Name: name}).First(&s).Error 138 | return err == nil 139 | } 140 | -------------------------------------------------------------------------------- /internal/db/setting.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | // Setting 为应用设置 8 | type Setting struct { 9 | gorm.Model 10 | 11 | Key string 12 | Value string 13 | } 14 | 15 | func GetSetting(key string, defaultValue ...string) (string, error) { 16 | var setting Setting 17 | if err := db.Model(&Setting{}).Where("`key` = ?", key).First(&setting).Error; err != nil { 18 | if err == gorm.ErrRecordNotFound { 19 | if len(defaultValue) > 0 { 20 | return defaultValue[0], nil 21 | } 22 | } 23 | return "", err 24 | } 25 | 26 | return setting.Value, nil 27 | } 28 | 29 | func MustGetSetting(key string, defaultValue ...string) string { 30 | value, _ := GetSetting(key, defaultValue...) 31 | return value 32 | } 33 | 34 | func SetSetting(key, value string) error { 35 | _, err := GetSetting(key) 36 | if err == gorm.ErrRecordNotFound { 37 | return db.Model(&Setting{}).Create(&Setting{ 38 | Key: key, 39 | Value: value, 40 | }).Error 41 | } 42 | return db.Model(&Setting{}).Where("`key` = ?", key).Update("value", value).Error 43 | } 44 | -------------------------------------------------------------------------------- /internal/db/ticket.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/thanhpk/randstr" 9 | ) 10 | 11 | // NewServiceTicket 生成一个 Ticket。 12 | func NewServiceTicket(service *Service, user *User) (string, error) { 13 | ticket := "ST-" + randstr.String(32) 14 | err := red.Set(ticket, fmt.Sprintf("%d|%d", service.ID, user.ID), -1).Err() 15 | if err != nil { 16 | return "", err 17 | } 18 | return ticket, nil 19 | } 20 | 21 | // ValidateServiceTicket 验证 Ticket 是否正确。 22 | func ValidateServiceTicket(service *Service, ticket string) (*User, bool) { 23 | u, s, ok := ValidateTicket(ticket) 24 | if !ok { 25 | return nil, false 26 | } 27 | if s.ID != service.ID { 28 | return nil, false 29 | } 30 | return u, true 31 | } 32 | 33 | // ValidateTicket 验证 Ticket 是否正确。 34 | func ValidateTicket(ticket string) (*User, *Service, bool) { 35 | ticketData, err := red.Get(ticket).Result() 36 | if ticketData == "" || err != nil { 37 | return nil, nil, false 38 | } 39 | ticketPart := strings.Split(ticketData, "|") 40 | if len(ticketPart) != 2 { 41 | return nil, nil, false 42 | } 43 | 44 | serviceID := ticketPart[0] 45 | userID := ticketPart[1] 46 | 47 | sid, err := strconv.Atoi(serviceID) 48 | if err != nil { 49 | return nil, nil, false 50 | } 51 | 52 | uid, err := strconv.Atoi(userID) 53 | if err != nil { 54 | return nil, nil, false 55 | } 56 | 57 | user := MustGetUserByID(uint(uid)) 58 | service, err := GetServiceByID(uint(sid)) 59 | return user, service, err == nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/db/user.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/subtle" 6 | "encoding/hex" 7 | "fmt" 8 | "log" 9 | "strings" 10 | "unicode/utf8" 11 | 12 | "github.com/pkg/errors" 13 | "github.com/thanhpk/randstr" 14 | "github.com/unknwon/com" 15 | "golang.org/x/crypto/pbkdf2" 16 | "gorm.io/gorm" 17 | 18 | "github.com/NekoWheel/NekoCAS/internal/conf" 19 | "github.com/NekoWheel/NekoCAS/internal/helper" 20 | ) 21 | 22 | // User 为用户表。 23 | type User struct { 24 | gorm.Model 25 | 26 | NickName string 27 | Email string 28 | Password string 29 | Salt string 30 | Avatar string 31 | 32 | IsActive bool 33 | IsAdmin bool 34 | IsLdap bool 35 | } 36 | 37 | // EncodePassword 对密码进行加盐处理。 38 | func (u *User) EncodePassword() { 39 | newPassword := pbkdf2.Key([]byte(u.Password), []byte(u.Salt), 10000, 50, sha256.New) 40 | u.Password = fmt.Sprintf("%x", newPassword) 41 | } 42 | 43 | // ValidatePassword 检查输入的密码是否正确。 44 | func (u *User) ValidatePassword(password string) bool { 45 | newUser := &User{Password: password, Salt: u.Salt} 46 | newUser.EncodePassword() 47 | return subtle.ConstantTimeCompare([]byte(u.Password), []byte(newUser.Password)) == 1 48 | } 49 | 50 | // GetActivationCode 返回用户账号激活码,有效期两小时。 51 | func (u *User) GetActivationCode() string { 52 | code := helper.CreateTimeLimitCode(com.ToStr(u.ID)+u.Email+u.NickName+u.Password+u.Salt, 120, nil) 53 | 54 | // 添加编码后的邮箱信息,用于验证时反查用户信息 55 | code += hex.EncodeToString([]byte(u.Email)) 56 | return code 57 | } 58 | 59 | // VerifyUserActiveCode 检查用户输入的账号激活码是否有效。 60 | func VerifyUserActiveCode(code string) *User { 61 | if len(code) <= helper.TIME_LIMIT_CODE_LENGTH { 62 | return nil 63 | } 64 | 65 | hexStr := code[helper.TIME_LIMIT_CODE_LENGTH:] 66 | if b, err := hex.DecodeString(hexStr); err == nil { 67 | if user, err := GetUserByEmail(string(b)); err != nil { 68 | return nil 69 | } else { 70 | prefix := code[:helper.TIME_LIMIT_CODE_LENGTH] 71 | data := com.ToStr(user.ID) + string(b) + user.NickName + user.Password + user.Salt 72 | 73 | if helper.VerifyTimeLimitCode(data, 120, prefix) { 74 | return user 75 | } 76 | } 77 | } 78 | return nil 79 | } 80 | 81 | // GetUserSalt 返回随机字符串作为用户的盐。 82 | func GetUserSalt() string { 83 | return randstr.String(10) 84 | } 85 | 86 | // CreateUser 新建一个新的用户。 87 | func CreateUser(u *User) error { 88 | if err := isUsernameAllowed(u.NickName); err != nil { 89 | return err 90 | } 91 | 92 | isExist := IsUserExist(u.NickName) 93 | if isExist { 94 | return ErrUserAlreadyExist{arg: u.NickName} 95 | } 96 | 97 | u.Email = strings.ToLower(u.Email) 98 | isExist = IsEmailUsed(u.Email) 99 | if isExist { 100 | return ErrEmailAlreadyUsed{arg: u.Email} 101 | } 102 | 103 | u.Avatar = helper.HashEmail(u.Email) 104 | if !u.IsLdap { 105 | u.Salt = GetUserSalt() 106 | u.EncodePassword() 107 | } 108 | 109 | if err := db.Create(u).Error; err != nil { 110 | return errors.Wrap(err, "添加用户") 111 | } 112 | return nil 113 | } 114 | 115 | var ErrBadCredential = errors.New("电子邮箱或密码错误") 116 | 117 | // UserAuthenticate 验证用户传入的用户名与密码。 118 | func UserAuthenticate(email string, password string) (*User, error) { 119 | user := new(User) 120 | if err := db.Model(&User{}).Where(&User{Email: email}).First(&user).Error; err != nil { 121 | return nil, ErrBadCredential 122 | } 123 | 124 | if conf.Ldap.Enabled && user.IsLdap { 125 | ok, err := ldapAuthenticate(email, password) 126 | if err != nil { 127 | log.Println("ldap auth error:", err) 128 | return nil, ErrBadCredential 129 | } 130 | if !ok { 131 | return nil, ErrBadCredential 132 | } 133 | } else { 134 | if !user.ValidatePassword(password) { 135 | return nil, ErrBadCredential 136 | } 137 | } 138 | 139 | return user, nil 140 | } 141 | 142 | // UpdateUserProfile 修改用户信息。 143 | func UpdateUserProfile(u *User) error { 144 | if u.Password != "" { 145 | u.EncodePassword() 146 | } 147 | 148 | return db.Model(&User{}).Where(&User{ 149 | Model: gorm.Model{ID: u.ID}, 150 | }).Updates(&User{ 151 | NickName: u.NickName, 152 | Password: u.Password, 153 | IsActive: u.IsActive, 154 | }).Error 155 | } 156 | 157 | // MustGetUserByID 根据传入的用户 ID 查询对应的用户。 158 | func MustGetUserByID(uid uint) *User { 159 | var u User 160 | _ = db.Model(&User{}).Where(&User{ 161 | Model: gorm.Model{ 162 | ID: uid, 163 | }, 164 | }).First(&u) 165 | return &u 166 | } 167 | 168 | var ErrUserNotFound = errors.New("用户不存在") 169 | 170 | // GetUserByEmail 根据给定的电子邮箱地址查询对应的用户。 171 | func GetUserByEmail(email string) (*User, error) { 172 | var u User 173 | if err := db.Model(&User{}).Where(&User{ 174 | Email: email, 175 | }).First(&u).Error; err != nil { 176 | if err == gorm.ErrRecordNotFound { 177 | return nil, ErrUserNotFound 178 | } 179 | return nil, errors.Wrap(err, "查询用户") 180 | } 181 | 182 | return &u, nil 183 | } 184 | 185 | // GetUserByNickName 根据给定的用户昵称查询对应的用户。 186 | func GetUserByNickName(nickName string) (*User, error) { 187 | var u User 188 | if err := db.Model(&User{}).Where(&User{ 189 | NickName: nickName, 190 | }).First(&u).Error; err != nil { 191 | if err == gorm.ErrRecordNotFound { 192 | return nil, ErrUserNotFound 193 | } 194 | return nil, errors.Wrap(err, "查询用户") 195 | } 196 | 197 | return &u, nil 198 | } 199 | 200 | // GetUsers 批量获取用户 201 | // options[0] offset 202 | // options[1] limit 203 | func GetUsers(options ...int) []*User { 204 | var users []*User 205 | 206 | if len(options) == 0 { 207 | db.Model(&User{}).Find(&users) 208 | } else { 209 | offset := 0 210 | if len(options) > 1 && options[0] > 0 { 211 | offset = options[0] 212 | } 213 | 214 | limit := 0 215 | if len(options) == 2 && options[1] > 0 { 216 | limit = options[1] 217 | } 218 | db.Model(&User{}).Offset(offset).Limit(limit).Find(&users) 219 | } 220 | 221 | return users 222 | } 223 | 224 | // CountUsers 返回用户的总数 225 | func CountUsers() int64 { 226 | var count int64 227 | db.Model(&User{}).Count(&count) 228 | return count 229 | } 230 | 231 | func isUsernameAllowed(name string) error { 232 | name = strings.TrimSpace(strings.ToLower(name)) 233 | if utf8.RuneCountInString(name) == 0 { 234 | return ErrNameNotAllowed{arg: name} 235 | } 236 | return nil 237 | } 238 | 239 | // IsUserExist 检查用户昵称是否重复。 240 | func IsUserExist(name string) bool { 241 | if name == "" { 242 | return false 243 | } 244 | var u User 245 | err := db.Model(&User{}).Where(&User{NickName: name}).First(&u).Error 246 | return err == nil 247 | } 248 | 249 | // IsEmailUsed 检查邮箱是否重复。 250 | func IsEmailUsed(email string) bool { 251 | if email == "" { 252 | return false 253 | } 254 | var u User 255 | err := db.Model(&User{}).Where(&User{Email: email}).First(&u).Error 256 | return err == nil 257 | } 258 | 259 | // IsNickNameUsed 检查用户昵称是否重复。 260 | // 受历史原因影响,仅用于LDAP同步时检查用户昵称是否重复。 261 | func IsNickNameUsed(name string) bool { 262 | if name == "" { 263 | return false 264 | } 265 | var u User 266 | err := db.Model(&User{}).Where(&User{NickName: name}).First(&u).Error 267 | return err == nil 268 | } 269 | 270 | type ErrUserAlreadyExist struct { 271 | arg interface{} 272 | } 273 | 274 | func IsErrUserAlreadyExist(err error) bool { 275 | _, ok := err.(ErrEmailAlreadyUsed) 276 | return ok 277 | } 278 | 279 | func (err ErrUserAlreadyExist) Error() string { 280 | return fmt.Sprintf("用户昵称已被使用: %v", err.arg) 281 | } 282 | 283 | type ErrEmailAlreadyUsed struct { 284 | arg interface{} 285 | } 286 | 287 | func IsErrEmailAlreadyUsed(err error) bool { 288 | _, ok := err.(ErrEmailAlreadyUsed) 289 | return ok 290 | } 291 | 292 | func (err ErrEmailAlreadyUsed) Error() string { 293 | return fmt.Sprintf("电子邮箱已被使用: %v", err.arg) 294 | } 295 | 296 | type ErrNameNotAllowed struct { 297 | arg interface{} 298 | } 299 | 300 | func IsErrNameNotAllowed(err error) bool { 301 | _, ok := err.(ErrNameNotAllowed) 302 | return ok 303 | } 304 | 305 | func (err ErrNameNotAllowed) Error() string { 306 | return fmt.Sprintf("用户名输入有误: %v", err.arg) 307 | } 308 | -------------------------------------------------------------------------------- /internal/filesystem/fs.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "embed" 5 | "io" 6 | "io/fs" 7 | "path" 8 | "path/filepath" 9 | "strings" 10 | 11 | "gopkg.in/macaron.v1" 12 | ) 13 | 14 | var _ macaron.TemplateFileSystem = (*FS)(nil) 15 | 16 | type FS struct { 17 | embed embed.FS 18 | } 19 | 20 | func NewFS(embedFS embed.FS) *FS { 21 | return &FS{ 22 | embed: embedFS, 23 | } 24 | } 25 | 26 | func (fs *FS) ListFiles() []macaron.TemplateFile { 27 | return fs.scanDir(".") 28 | } 29 | 30 | func (fs *FS) scanDir(dirPath string) []macaron.TemplateFile { 31 | templateFiles := make([]macaron.TemplateFile, 0) 32 | 33 | dirEntry, err := fs.embed.ReadDir(dirPath) 34 | if err != nil { 35 | return nil 36 | } 37 | 38 | for _, entry := range dirEntry { 39 | entryPath := path.Join(dirPath, entry.Name()) 40 | if entry.IsDir() { 41 | templateFiles = append(templateFiles, fs.scanDir(entryPath)...) 42 | continue 43 | } 44 | 45 | fileReader, err := fs.embed.Open(entryPath) 46 | if err != nil { 47 | continue 48 | } 49 | fileData, err := io.ReadAll(fileReader) 50 | if err != nil { 51 | continue 52 | } 53 | 54 | f := &file{ 55 | DirEntry: entry, 56 | entryPath: entryPath, 57 | data: fileData, 58 | } 59 | templateFiles = append(templateFiles, f) 60 | } 61 | return templateFiles 62 | } 63 | 64 | func (fs *FS) Get(fileName string) (io.Reader, error) { 65 | return fs.embed.Open(fileName) 66 | } 67 | 68 | var _ macaron.TemplateFile = (*file)(nil) 69 | 70 | type file struct { 71 | fs.DirEntry 72 | entryPath string 73 | data []byte 74 | } 75 | 76 | func (f *file) Name() string { 77 | if f.Ext() == ".html" || f.Ext() == ".tmpl" { 78 | return strings.TrimSuffix(f.entryPath, f.Ext()) 79 | } 80 | return f.entryPath 81 | } 82 | 83 | func (f *file) Data() []byte { 84 | return f.data 85 | } 86 | 87 | func (f *file) Ext() string { 88 | return filepath.Ext(f.DirEntry.Name()) 89 | } 90 | -------------------------------------------------------------------------------- /internal/form/account.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "github.com/go-macaron/binding" 5 | "gopkg.in/macaron.v1" 6 | ) 7 | 8 | type Register struct { 9 | Mail string `binding:"Required;Email;MaxSize(254)" locale:"电子邮箱"` 10 | NickName string `binding:"Required;MaxSize(20)" locale:"昵称"` 11 | Password string `binding:"Required;MaxSize(255)" locale:"密码"` 12 | Retype string 13 | Privacy string 14 | } 15 | 16 | func (f *Register) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 17 | return validate(errs, ctx.Data, f) 18 | } 19 | 20 | type Login struct { 21 | Mail string `binding:"Required;Email;MaxSize(254)" locale:"电子邮箱"` 22 | Password string `binding:"Required;MaxSize(255)" locale:"密码"` 23 | } 24 | 25 | func (f *Login) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 26 | return validate(errs, ctx.Data, f) 27 | } 28 | 29 | type UpdateProfile struct { 30 | NickName string `binding:"Required;MaxSize(20)" locale:"昵称"` 31 | Password string `binding:"MaxSize(255)" locale:"密码"` 32 | Retype string 33 | } 34 | 35 | func (f *UpdateProfile) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 36 | return validate(errs, ctx.Data, f) 37 | } 38 | 39 | type LostPassword struct { 40 | Email string `binding:"Required;Email;MaxSize(254)" locale:"电子邮箱"` 41 | } 42 | 43 | func (f *LostPassword) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 44 | return validate(errs, ctx.Data, f) 45 | } 46 | 47 | type ResetPassword struct { 48 | Password string `binding:"Required;MaxSize(255)" locale:"密码"` 49 | Retype string 50 | } 51 | 52 | func (f *ResetPassword) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 53 | return validate(errs, ctx.Data, f) 54 | } 55 | -------------------------------------------------------------------------------- /internal/form/form.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/go-macaron/binding" 9 | "github.com/unknwon/com" 10 | ) 11 | 12 | type Form interface { 13 | binding.Validator 14 | } 15 | 16 | func init() { 17 | binding.SetNameMapper(com.ToSnakeCase) 18 | } 19 | 20 | // Assign 将字段值返回表单 21 | func Assign(form interface{}, data map[string]interface{}) { 22 | typ := reflect.TypeOf(form) 23 | val := reflect.ValueOf(form) 24 | 25 | if typ.Kind() == reflect.Ptr { 26 | typ = typ.Elem() 27 | val = val.Elem() 28 | } 29 | 30 | for i := 0; i < typ.NumField(); i++ { 31 | field := typ.Field(i) 32 | 33 | fieldName := field.Tag.Get("form") 34 | // Allow ignored fields in the struct 35 | if fieldName == "-" { 36 | continue 37 | } else if len(fieldName) == 0 { 38 | fieldName = com.ToSnakeCase(field.Name) 39 | } 40 | 41 | data[fieldName] = val.Field(i).Interface() 42 | } 43 | } 44 | 45 | func validate(errs binding.Errors, data map[string]interface{}, f Form) binding.Errors { 46 | if errs.Len() == 0 { 47 | return errs 48 | } 49 | 50 | data["HasError"] = true 51 | Assign(f, data) 52 | 53 | typ := reflect.TypeOf(f) 54 | val := reflect.ValueOf(f) 55 | 56 | if typ.Kind() == reflect.Ptr { 57 | typ = typ.Elem() 58 | val = val.Elem() 59 | } 60 | 61 | for i := 0; i < typ.NumField(); i++ { 62 | field := typ.Field(i) 63 | 64 | fieldName := field.Tag.Get("form") 65 | // 忽略的字段 66 | if fieldName == "-" { 67 | continue 68 | } 69 | 70 | // 报错信息字段名 71 | trName := field.Tag.Get("locale") 72 | if len(trName) == 0 { 73 | trName = fieldName 74 | } 75 | 76 | if errs[0].FieldNames[0] == field.Name { 77 | switch errs[0].Classification { 78 | case binding.ERR_REQUIRED: 79 | data["ErrorMsg"] = trName + "不能为空" 80 | case binding.ERR_ALPHA_DASH: 81 | data["ErrorMsg"] = trName + "必须为英文字母、阿拉伯数字或横线(-_)" 82 | case binding.ERR_ALPHA_DASH_DOT: 83 | data["ErrorMsg"] = trName + "必须为英文字母、阿拉伯数字、横线(-_)或点" 84 | case binding.ERR_SIZE: 85 | data["ErrorMsg"] = trName + fmt.Sprintf("长度必须为 %s", getSize(field)) 86 | case binding.ERR_MIN_SIZE: 87 | data["ErrorMsg"] = trName + fmt.Sprintf("长度最小为 %s 个字符", getMinSize(field)) 88 | case binding.ERR_MAX_SIZE: 89 | data["ErrorMsg"] = trName + fmt.Sprintf("长度最大为 %s 个字符", getMaxSize(field)) 90 | case binding.ERR_EMAIL: 91 | data["ErrorMsg"] = trName + "不是一个有效的邮箱地址" 92 | case binding.ERR_URL: 93 | data["ErrorMsg"] = trName + "不是一个有效的 URL" 94 | default: 95 | data["ErrorMsg"] = "未知错误" + " " + errs[0].Classification 96 | } 97 | return errs 98 | } 99 | } 100 | return errs 101 | } 102 | 103 | func getRuleBody(field reflect.StructField, prefix string) string { 104 | for _, rule := range strings.Split(field.Tag.Get("binding"), ";") { 105 | if strings.HasPrefix(rule, prefix) { 106 | return rule[len(prefix) : len(rule)-1] 107 | } 108 | } 109 | return "" 110 | } 111 | 112 | func getSize(field reflect.StructField) string { 113 | return getRuleBody(field, "Size(") 114 | } 115 | 116 | func getMinSize(field reflect.StructField) string { 117 | return getRuleBody(field, "MinSize(") 118 | } 119 | 120 | func getMaxSize(field reflect.StructField) string { 121 | return getRuleBody(field, "MaxSize(") 122 | } 123 | -------------------------------------------------------------------------------- /internal/form/service.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "github.com/go-macaron/binding" 5 | "gopkg.in/macaron.v1" 6 | ) 7 | 8 | type NewService struct { 9 | Name string `binding:"Required;MaxSize(254)" locale:"服务名"` 10 | Avatar string `binding:"Required;MaxSize(255)" locale:"服务 Logo 链接"` 11 | Domain string `binding:"Required;MaxSize(255)" locale:"白名单域名"` 12 | } 13 | 14 | func (f *NewService) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 15 | return validate(errs, ctx.Data, f) 16 | } 17 | 18 | type EditService struct { 19 | Name string `binding:"Required;MaxSize(254)" locale:"服务名"` 20 | Avatar string `binding:"Required;MaxSize(255)" locale:"服务 Logo 链接"` 21 | Domain string `binding:"Required;MaxSize(255)" locale:"白名单域名"` 22 | } 23 | 24 | func (f *EditService) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 25 | return validate(errs, ctx.Data, f) 26 | } 27 | -------------------------------------------------------------------------------- /internal/form/site.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "github.com/go-macaron/binding" 5 | "gopkg.in/macaron.v1" 6 | ) 7 | 8 | type Site struct { 9 | OpenRegister bool `locale:"是否开放注册"` 10 | SiteLogo string `binding:"MaxSize(255)" locale:"站点图标"` 11 | MailWhitelist string `binding:"MaxSize(255)" locale:"注册邮箱白名单"` 12 | Privacy string `locale:"隐私政策"` 13 | } 14 | 15 | func (f *Site) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { 16 | return validate(errs, ctx.Data, f) 17 | } 18 | -------------------------------------------------------------------------------- /internal/helper/helper.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/unknwon/com" 12 | 13 | "github.com/NekoWheel/NekoCAS/internal/conf" 14 | ) 15 | 16 | // HashEmail 将邮箱地址 Md5 转换成 Avatar 头像哈希 17 | // https://en.gravatar.com/site/implement/hash/ 18 | func HashEmail(email string) string { 19 | email = strings.ToLower(strings.TrimSpace(email)) 20 | h := md5.New() 21 | _, _ = h.Write([]byte(email)) 22 | return hex.EncodeToString(h.Sum(nil)) 23 | } 24 | 25 | const TIME_LIMIT_CODE_LENGTH = 12 + 6 + 40 26 | 27 | // CreateTimeLimitCode 根据给定的数据生成带过期时间的码。 28 | // 格式: 12 日期 + 6 分钟 + 40 sha1 29 | func CreateTimeLimitCode(data string, minutes int, startInf interface{}) string { 30 | format := "200601021504" 31 | 32 | var start, end time.Time 33 | var startStr, endStr string 34 | 35 | if startInf == nil { 36 | start = time.Now() 37 | startStr = start.Format(format) 38 | } else { 39 | startStr = startInf.(string) 40 | start, _ = time.ParseInLocation(format, startStr, time.Local) 41 | startStr = start.Format(format) 42 | } 43 | 44 | end = start.Add(time.Minute * time.Duration(minutes)) 45 | endStr = end.Format(format) 46 | 47 | sh := sha1.New() 48 | _, _ = sh.Write([]byte(data + conf.Site.SecurityKey + startStr + endStr + com.ToStr(minutes))) 49 | encoded := hex.EncodeToString(sh.Sum(nil)) 50 | 51 | code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded) 52 | return code 53 | } 54 | 55 | // VerifyTimeLimitCode 检查限时验证码是否正确。 56 | func VerifyTimeLimitCode(data string, minutes int, code string) bool { 57 | if len(code) <= 18 { 58 | return false 59 | } 60 | 61 | start := code[:12] 62 | lives := code[12:18] 63 | if d, err := com.StrTo(lives).Int(); err == nil { 64 | minutes = d 65 | } 66 | 67 | retCode := CreateTimeLimitCode(data, minutes, start) 68 | if retCode == code && minutes > 0 { 69 | // 检查是否超时 70 | before, _ := time.ParseInLocation("200601021504", start, time.Local) 71 | now := time.Now() 72 | if before.Add(time.Minute*time.Duration(minutes)).Unix() > now.Unix() { 73 | return true 74 | } 75 | } 76 | 77 | return false 78 | } 79 | 80 | // Substr 根据给定的开始位置和长度裁剪字符串。 81 | func Substr(s string, start, length int) string { 82 | bt := []rune(s) 83 | if start < 0 { 84 | start = 0 85 | } 86 | if start > len(bt) { 87 | start = start % len(bt) 88 | } 89 | var end int 90 | if (start + length) > (len(bt) - 1) { 91 | end = len(bt) 92 | } else { 93 | end = start + length 94 | } 95 | return string(bt[start:end]) 96 | } 97 | -------------------------------------------------------------------------------- /internal/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "sync" 7 | 8 | "gopkg.in/gomail.v2" 9 | "gopkg.in/macaron.v1" 10 | 11 | "github.com/NekoWheel/NekoCAS/internal/conf" 12 | "github.com/NekoWheel/NekoCAS/internal/filesystem" 13 | templ "github.com/NekoWheel/NekoCAS/internal/web/template" 14 | "github.com/NekoWheel/NekoCAS/templates" 15 | ) 16 | 17 | var ( 18 | tplRender *macaron.TplRender 19 | tplRenderOnce sync.Once 20 | ) 21 | 22 | // render 根据给定的信息渲染邮件模板 23 | func render(tpl string, data map[string]interface{}) (string, error) { 24 | tplRenderOnce.Do(func() { 25 | var templateFS macaron.TemplateFileSystem 26 | if macaron.Env == macaron.PROD { 27 | templateFS = filesystem.NewFS(templates.FS) 28 | } 29 | 30 | opt := &macaron.RenderOptions{ 31 | IndentJSON: macaron.Env != macaron.PROD, 32 | Funcs: templ.FuncMap(), 33 | TemplateFileSystem: templateFS, 34 | } 35 | 36 | ts := macaron.NewTemplateSet() 37 | ts.Set(macaron.DEFAULT_TPL_SET_NAME, opt) 38 | tplRender = &macaron.TplRender{ 39 | TemplateSet: ts, 40 | Opt: opt, 41 | } 42 | }) 43 | 44 | return tplRender.HTMLString(tpl, data) 45 | } 46 | 47 | func SendActivationMail(to, code string) error { 48 | data := map[string]interface{}{ 49 | "SiteName": conf.Site.Name, 50 | "Email": to, 51 | "Link": conf.Site.BaseURL + "/activate_code?code=" + code, 52 | } 53 | body, err := render("mail/activate", data) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | title := fmt.Sprintf("激活您的 %s 账号", conf.Site.Name) 59 | return send(to, title, body) 60 | } 61 | 62 | func SendLostPasswordMail(to, code string) error { 63 | data := map[string]interface{}{ 64 | "SiteName": conf.Site.Name, 65 | "Email": to, 66 | "Link": conf.Site.BaseURL + "/reset_password?code=" + code, 67 | } 68 | body, err := render("mail/reset_password", data) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | title := fmt.Sprintf("您正在找回您的 %s 账号密码", conf.Site.Name) 74 | return send(to, title, body) 75 | } 76 | 77 | func send(to, title, content string) error { 78 | m := gomail.NewMessage() 79 | m.SetHeader("From", conf.Mail.Account) 80 | m.SetHeader("To", to) 81 | m.SetHeader("Subject", title) 82 | m.SetBody("text/html", content) 83 | 84 | d := gomail.NewDialer( 85 | conf.Mail.SMTP, 86 | conf.Mail.Port, 87 | conf.Mail.Account, 88 | conf.Mail.Password, 89 | ) 90 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} 91 | return d.DialAndSend(m) 92 | } 93 | -------------------------------------------------------------------------------- /internal/spec/v1/validate.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/NekoWheel/NekoCAS/internal/context" 8 | "github.com/NekoWheel/NekoCAS/internal/db" 9 | ) 10 | 11 | func ValidateHandler(c *context.Context) { 12 | ticket := c.Query("ticket") 13 | user, ok := db.ValidateServiceTicket(c.Service, ticket) 14 | if ok { 15 | c.PlainText(http.StatusOK, []byte(fmt.Sprintf("yes\n%s\n", user.NickName))) 16 | } 17 | c.PlainText(http.StatusOK, []byte("no\n")) 18 | } 19 | -------------------------------------------------------------------------------- /internal/spec/v2/type.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/NekoWheel/NekoCAS/internal/db" 7 | ) 8 | 9 | type CASServiceResponse struct { 10 | XMLName xml.Name `xml:"cas:serviceResponse"` 11 | Xmlns string `xml:"xmlns:cas,attr"` 12 | Success *CASAuthenticationSuccess 13 | Failure *CASAuthenticationFailure 14 | ProxySuccess *CASProxySuccess 15 | ProxyFailure *CASProxyFailure 16 | } 17 | 18 | type CASAuthenticationSuccess struct { 19 | XMLName xml.Name `xml:"cas:authenticationSuccess"` 20 | User CASUser 21 | Attributes CASAttributes 22 | } 23 | 24 | type CASAuthenticationFailure struct { 25 | XMLName xml.Name `xml:"cas:authenticationFailure"` 26 | Code string `xml:"code,attr"` 27 | Message string `xml:",chardata"` 28 | } 29 | 30 | type CASUser struct { 31 | XMLName xml.Name `xml:"cas:user"` 32 | User string `xml:",chardata"` 33 | } 34 | 35 | type CASPgtIou struct { 36 | XMLName xml.Name `xml:"cas:proxyGrantingTicket"` 37 | Ticket string `xml:",chardata"` 38 | } 39 | 40 | type CASAttributes struct { 41 | XMLName xml.Name `xml:"cas:attributes"` 42 | Email string 43 | } 44 | 45 | type CASProxySuccess struct { 46 | XMLName xml.Name `xml:"cas:proxyTicket"` 47 | Ticket string `xml:",chardata"` 48 | } 49 | 50 | type CASProxyFailure struct { 51 | XMLName xml.Name `xml:"cas:proxyFailure"` 52 | Code string `xml:"string"` 53 | Message string `xml:",chardata"` 54 | } 55 | 56 | // newCASResponse 创建一个新的 CAS XML 返回。 57 | func newCASResponse() CASServiceResponse { 58 | return CASServiceResponse{ 59 | Xmlns: "http://www.yale.edu/tp/cas", 60 | } 61 | } 62 | 63 | // NewCASSuccessResponse 创建一个 CAS XML 成功返回,包含用户信息。 64 | func NewCASSuccessResponse(u *db.User) []byte { 65 | s := newCASResponse() 66 | s.Success = &CASAuthenticationSuccess{ 67 | User: CASUser{User: u.NickName}, 68 | Attributes: CASAttributes{ 69 | Email: u.Email, 70 | }, 71 | } 72 | x, _ := xml.Marshal(s) 73 | return x 74 | } 75 | 76 | // NewCASFailureResponse 创建一个 CAS XML 失败返回,包含错误码以及错误信息。 77 | func NewCASFailureResponse(c string, msg string) []byte { 78 | f := newCASResponse() 79 | f.Failure = &CASAuthenticationFailure{ 80 | Code: c, 81 | Message: msg, 82 | } 83 | x, _ := xml.Marshal(f) 84 | return x 85 | } 86 | 87 | // NewCASProxySuccessResponse 创建一个 CAS Proxy XML 成功返回,包含 Ticket。 88 | func NewCASProxySuccessResponse(pt string) []byte { 89 | s := newCASResponse() 90 | s.ProxySuccess = &CASProxySuccess{ 91 | Ticket: pt, 92 | } 93 | x, _ := xml.Marshal(s) 94 | return x 95 | } 96 | 97 | // NewCASProxyFailureResponse 创建一个 CAS Proxy XML 失败返回,包含错误码以及错误信息。 98 | func NewCASProxyFailureResponse(c string, msg string) []byte { 99 | f := newCASResponse() 100 | f.ProxyFailure = &CASProxyFailure{ 101 | Code: c, 102 | Message: msg, 103 | } 104 | x, _ := xml.Marshal(f) 105 | return x 106 | } 107 | -------------------------------------------------------------------------------- /internal/spec/v2/validate.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/NekoWheel/NekoCAS/internal/context" 7 | "github.com/NekoWheel/NekoCAS/internal/db" 8 | ) 9 | 10 | func ValidateHandler(c *context.Context) { 11 | c.Header().Set("Content-Type", "text/xml") 12 | 13 | ticket := c.Query("ticket") 14 | service := c.Service 15 | if service == nil || ticket == "" { 16 | c.PlainText(http.StatusOK, NewCASFailureResponse("INVALID_REQUEST", "Both ticket and service parameters must be given")) 17 | return 18 | } 19 | 20 | ticketUser, ticketService, ok := db.ValidateTicket(ticket) 21 | if !ok { 22 | c.PlainText(http.StatusOK, NewCASFailureResponse("INVALID_TICKET", "Ticket not recognized")) 23 | return 24 | } 25 | if ticketService.ID != service.ID { 26 | c.PlainText(http.StatusOK, NewCASFailureResponse("INVALID_SERVICE", "Ticket was used for another service than it was generated for")) 27 | return 28 | } 29 | 30 | c.PlainText(http.StatusOK, NewCASSuccessResponse(ticketUser)) 31 | } 32 | -------------------------------------------------------------------------------- /internal/web/account/activation.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "github.com/go-macaron/cache" 5 | "github.com/unknwon/com" 6 | "gorm.io/gorm" 7 | log "unknwon.dev/clog/v2" 8 | 9 | "github.com/NekoWheel/NekoCAS/internal/context" 10 | "github.com/NekoWheel/NekoCAS/internal/db" 11 | "github.com/NekoWheel/NekoCAS/internal/mail" 12 | ) 13 | 14 | func ActivationViewHandler(c *context.Context, cache cache.Cache) { 15 | key := "Activate_Mail_" + com.ToStr(c.User.ID) 16 | if !cache.IsExist(key) { 17 | code := c.User.GetActivationCode() 18 | err := mail.SendActivationMail(c.User.Email, code) 19 | if err != nil { 20 | log.Error("Failed to send activation email to %q with error: %v", c.User.Email, err) 21 | } 22 | 23 | _ = cache.Put(key, true, 120) 24 | } 25 | 26 | c.Success("activate") 27 | } 28 | 29 | func ActivationActionHandler(c *context.Context, cache cache.Cache) { 30 | key := "Activate_Mail_" + com.ToStr(c.User.ID) 31 | if !cache.IsExist(key) { 32 | code := c.User.GetActivationCode() 33 | err := mail.SendActivationMail(c.User.Email, code) 34 | if err != nil { 35 | log.Error("Failed to send activation email to %q with error: %v", c.User.Email, err) 36 | c.RenderWithErr("服务内部错误,发送邮件失败!", "activate", nil) 37 | return 38 | } 39 | 40 | _ = cache.Put(key, 1, 120) 41 | } else { 42 | c.Flash.Error("邮件发送过于频繁,请等待 2 分钟后再尝试。") 43 | } 44 | c.Redirect("/activate") 45 | } 46 | 47 | func VerifyUserActiveCodeHandler(c *context.Context) { 48 | code := c.QueryTrim("code") 49 | if code == "" { 50 | c.Redirect("/") 51 | return 52 | } 53 | 54 | defer c.Redirect("/login") 55 | 56 | user := db.VerifyUserActiveCode(code) 57 | if user == nil { 58 | c.Flash.Error("账号激活码无效。") 59 | return 60 | } 61 | 62 | err := db.UpdateUserProfile(&db.User{ 63 | Model: gorm.Model{ 64 | ID: user.ID, 65 | }, 66 | IsActive: true, 67 | }) 68 | if err != nil { 69 | c.Flash.Error("账号激活失败。") 70 | return 71 | } 72 | 73 | c.Flash.Success("账号激活成功,请登录。") 74 | } 75 | -------------------------------------------------------------------------------- /internal/web/account/dashboard.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "github.com/NekoWheel/NekoCAS/internal/context" 5 | ) 6 | 7 | func DashboardViewHandler(c *context.Context) { 8 | c.Success("dashboard") 9 | } 10 | -------------------------------------------------------------------------------- /internal/web/account/login.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | 7 | "github.com/NekoWheel/NekoCAS/internal/context" 8 | "github.com/NekoWheel/NekoCAS/internal/db" 9 | "github.com/NekoWheel/NekoCAS/internal/form" 10 | ) 11 | 12 | func LoginViewHandler(c *context.Context) { 13 | if !c.IsLogged { 14 | c.Success("login") 15 | return 16 | } 17 | 18 | // 带有 ServiceURL,携带 Ticket 跳转到对应服务 19 | if c.Service.ID != 0 { 20 | redirectWithTicket(c) 21 | return 22 | } 23 | 24 | // 未含 ServiceURL,跳转到首页 25 | c.Redirect("/") 26 | } 27 | 28 | func LoginActionHandler(c *context.Context, f form.Login) { 29 | // 已登录用户不允许再提交登录表单 30 | if c.IsLogged { 31 | c.Redirect("/", 302) 32 | return 33 | } 34 | 35 | // 登录表单报错 36 | if c.HasError() { 37 | c.Success("login") 38 | return 39 | } 40 | 41 | u, err := db.UserAuthenticate(f.Mail, f.Password) 42 | if err != nil { 43 | c.RenderWithErr(err.Error(), "login", f) 44 | return 45 | } 46 | 47 | c.User = u 48 | _ = c.Session.Set("uid", u.ID) 49 | 50 | // 判断用户是否已经激活,Ldap用户跳过激活。 51 | if !c.User.IsLdap && !c.User.IsActive { 52 | c.Redirect("/") 53 | return 54 | } 55 | 56 | // 携带 Ticket 跳转到对应服务 57 | if c.Service.ID != 0 { 58 | redirectWithTicket(c) 59 | return 60 | } 61 | c.Redirect("/") 62 | } 63 | 64 | func redirectWithTicket(c *context.Context) { 65 | ticket, err := db.NewServiceTicket(c.Service, c.User) 66 | if err != nil { 67 | c.Error(err) 68 | return 69 | } 70 | 71 | // 解析跳转 URL 72 | redirectURL, err := url.Parse(c.ServiceURL) 73 | if err != nil { 74 | c.Error(errors.New("解析 URL 失败")) 75 | return 76 | } 77 | query := redirectURL.Query() 78 | query.Set("ticket", ticket) 79 | redirectURL.RawQuery = query.Encode() 80 | c.Redirect(redirectURL.String()) 81 | } 82 | -------------------------------------------------------------------------------- /internal/web/account/logout.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "github.com/NekoWheel/NekoCAS/internal/context" 5 | ) 6 | 7 | func LogoutViewHandler(c *context.Context) { 8 | c.Data["Service"] = c.Service 9 | c.Success("logout") 10 | } 11 | 12 | func LogoutActionHandler(c *context.Context) { 13 | c.Data["Service"] = c.Service 14 | _ = c.Session.Destory(c.Context) 15 | c.Success("logout") 16 | } 17 | -------------------------------------------------------------------------------- /internal/web/account/lost_password.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-macaron/cache" 7 | "github.com/unknwon/com" 8 | 9 | "github.com/NekoWheel/NekoCAS/internal/context" 10 | "github.com/NekoWheel/NekoCAS/internal/db" 11 | "github.com/NekoWheel/NekoCAS/internal/form" 12 | "github.com/NekoWheel/NekoCAS/internal/mail" 13 | ) 14 | 15 | func LostPasswordHandler(c *context.Context) { 16 | c.Success("lost_password") 17 | } 18 | 19 | func LostPasswordActionHandler(c *context.Context, f form.LostPassword, cache cache.Cache) { 20 | user, err := db.GetUserByEmail(f.Email) 21 | if err != nil { 22 | c.RenderWithErr("该邮箱不存在", "lost_password", nil) 23 | return 24 | } 25 | 26 | c.Flash.Success(fmt.Sprintf("找回密码邮件发送成功,请检查邮箱 %s。", user.Email)) 27 | 28 | key := "Lost_Password_" + com.ToStr(user.ID) 29 | if !cache.IsExist(key) { 30 | code := user.GetActivationCode() 31 | err := mail.SendLostPasswordMail(user.Email, code) 32 | if err != nil { 33 | c.RenderWithErr("服务内部错误,发送邮件失败!", "lost_password", nil) 34 | return 35 | } 36 | _ = cache.Put(key, 1, 120) 37 | } else { 38 | c.Flash.Error("邮件发送过于频繁,请等待 2 分钟后再尝试。") 39 | } 40 | 41 | c.Redirect("/lost_password") 42 | } 43 | 44 | func ResetPasswordHandler(c *context.Context) { 45 | code := c.QueryTrim("code") 46 | if code == "" { 47 | c.Redirect("/") 48 | return 49 | } 50 | 51 | user := db.VerifyUserActiveCode(code) 52 | if user == nil { 53 | c.Flash.Error("重置密码链接无效。") 54 | c.Redirect("/login") 55 | return 56 | } 57 | 58 | c.Data["Email"] = user.Email 59 | 60 | c.Success("reset_password") 61 | } 62 | 63 | func ResetPasswordActionHandler(c *context.Context, f form.ResetPassword) { 64 | code := c.QueryTrim("code") 65 | if code == "" { 66 | c.Redirect("/") 67 | return 68 | } 69 | 70 | user := db.VerifyUserActiveCode(code) 71 | if user == nil { 72 | c.Flash.Error("重置密码链接无效。") 73 | c.Redirect("/login") 74 | return 75 | } 76 | 77 | c.Data["Email"] = user.Email 78 | 79 | // 表单报错 80 | if c.HasError() { 81 | c.Success("reset_password") 82 | return 83 | } 84 | 85 | if f.Password != f.Retype { 86 | c.Flash.Error("两次输入的密码不相同。") 87 | c.Redirect(c.Req.URL.String()) 88 | return 89 | } 90 | 91 | user.Password = f.Password 92 | err := db.UpdateUserProfile(user) 93 | if err != nil { 94 | c.Flash.Error("服务内部错误,密码重置失败。") 95 | c.Redirect(c.Req.URL.String()) 96 | return 97 | } 98 | 99 | c.Flash.Success("密码重置成功,请登录。") 100 | c.Redirect("/login") 101 | } 102 | -------------------------------------------------------------------------------- /internal/web/account/profile.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "github.com/NekoWheel/NekoCAS/internal/context" 5 | "github.com/NekoWheel/NekoCAS/internal/db" 6 | "github.com/NekoWheel/NekoCAS/internal/form" 7 | ) 8 | 9 | func ProfileViewHandler(c *context.Context) { 10 | c.Success("profile") 11 | } 12 | 13 | func ProfileEditViewHandler(c *context.Context) { 14 | c.Success("profile_edit") 15 | } 16 | 17 | func ProfileEditActionHandler(c *context.Context, f form.UpdateProfile) { 18 | // 表单报错 19 | if c.HasError() { 20 | c.Success("profile_edit") 21 | return 22 | } 23 | 24 | // 检查昵称是否使用 25 | u, err := db.GetUserByNickName(f.NickName) 26 | if err == nil && u.ID != c.User.ID { 27 | c.Flash.Error("用户昵称已被使用,换一个吧~") 28 | c.Redirect("/profile/edit", 302) 29 | return 30 | } 31 | 32 | if f.Password != f.Retype { 33 | c.Flash.Error("两次输入的密码不匹配") 34 | c.Redirect("/profile/edit", 302) 35 | return 36 | } 37 | 38 | c.User.NickName = f.NickName 39 | c.User.Password = f.Password 40 | 41 | if err := db.UpdateUserProfile(c.User); err != nil { 42 | c.Flash.Error("修改个人信息失败") 43 | c.Redirect("/profile/edit", 302) 44 | return 45 | } 46 | 47 | c.Flash.Success("修改个人信息成功") 48 | c.Redirect("/profile/edit", 302) 49 | } 50 | -------------------------------------------------------------------------------- /internal/web/account/register.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-macaron/cache" 7 | log "unknwon.dev/clog/v2" 8 | 9 | "github.com/NekoWheel/NekoCAS/internal/context" 10 | "github.com/NekoWheel/NekoCAS/internal/db" 11 | "github.com/NekoWheel/NekoCAS/internal/form" 12 | "github.com/NekoWheel/NekoCAS/internal/mail" 13 | ) 14 | 15 | func RegisterViewHandler(c *context.Context) { 16 | c.Success("register") 17 | } 18 | 19 | func RegisterActionHandler(c *context.Context, f form.Register, cache cache.Cache) { 20 | if c.Setting.OpenRegister != "on" { 21 | c.RenderWithErr("当前未开放注册", "register", &f) 22 | return 23 | } 24 | 25 | // 检查域名白名单 26 | c.Setting.MailWhitelist = strings.TrimSpace(c.Setting.MailWhitelist) 27 | if c.Setting.MailWhitelist != "" { 28 | mailWhitelist := strings.Split(c.Setting.MailWhitelist, ",") 29 | mailPart := strings.Split(f.Mail, "@") 30 | if len(mailPart) >= 2 { 31 | mailDomain := mailPart[1] 32 | 33 | ok := false 34 | for _, domain := range mailWhitelist { 35 | if domain == mailDomain { 36 | ok = true 37 | break 38 | } 39 | } 40 | if !ok { 41 | c.RenderWithErr("该邮箱域名不在白名单中", "register", &f) 42 | return 43 | } 44 | } 45 | } 46 | 47 | if c.HasError() { 48 | c.Success("register") 49 | return 50 | } 51 | 52 | if f.Password != f.Retype { 53 | c.RenderWithErr("两次输入的密码不匹配", "register", &f) 54 | return 55 | } 56 | 57 | if f.Privacy == "" { 58 | c.RenderWithErr("请同意隐私政策", "register", &f) 59 | return 60 | } 61 | 62 | u := &db.User{ 63 | NickName: f.NickName, 64 | Email: f.Mail, 65 | Password: f.Password, 66 | } 67 | 68 | users := db.GetUsers(0, 1) 69 | if len(users) == 0 { 70 | // 第一个注册的用户设置成管理员。 71 | u.IsAdmin = true 72 | log.Info("Set %q as admin", f.Mail) 73 | } 74 | 75 | if err := db.CreateUser(u); err != nil { 76 | switch { 77 | case db.IsErrUserAlreadyExist(err), db.IsErrEmailAlreadyUsed(err), db.IsErrNameNotAllowed(err): 78 | c.RenderWithErr(err.Error(), "register", &f) 79 | default: 80 | c.RenderWithErr(err.Error(), "register", &f) 81 | } 82 | return 83 | } 84 | log.Trace("Account created: %s", u.Email) 85 | 86 | // 发送账号激活邮件 87 | code := u.GetActivationCode() 88 | go func() { 89 | err := mail.SendActivationMail(u.Email, code) 90 | if err != nil { 91 | log.Error("Failed to send activation email to %q with error %v", u.Email, err) 92 | } 93 | }() 94 | 95 | c.Flash.Success("注册成功!") 96 | c.Redirect("/login") 97 | } 98 | -------------------------------------------------------------------------------- /internal/web/manager/service.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | "gorm.io/gorm" 8 | log "unknwon.dev/clog/v2" 9 | 10 | "github.com/NekoWheel/NekoCAS/internal/context" 11 | "github.com/NekoWheel/NekoCAS/internal/db" 12 | "github.com/NekoWheel/NekoCAS/internal/form" 13 | ) 14 | 15 | func ServicesViewHandler(c *context.Context) { 16 | total := db.CountServices() 17 | limit := 10 18 | page := c.QueryInt("p") 19 | if page <= 0 { 20 | page = 1 21 | } 22 | 23 | totalPage := int(total/int64(limit)) + 1 24 | if page > totalPage { 25 | page = totalPage 26 | } 27 | 28 | c.Data["From"] = (page-1)*limit + 1 29 | c.Data["To"] = page * limit 30 | 31 | c.Data["NextPage"] = page + 1 32 | c.Data["PreviousPage"] = page - 1 33 | c.Data["LastPage"] = total/int64(limit) + 1 34 | 35 | c.Data["Total"] = total 36 | 37 | services, err := db.GetServices((page-1)*limit, limit) 38 | if err != nil { 39 | log.Error("Failed to get services: %v", err) 40 | c.Error(errors.New("服务内部错误")) 41 | return 42 | } 43 | c.Data["Services"] = services 44 | 45 | c.Success("manage/services") 46 | } 47 | 48 | func NewServiceViewHandler(c *context.Context) { 49 | c.Success("manage/new_service") 50 | } 51 | 52 | func NewServiceActionHandler(c *context.Context, f form.NewService) { 53 | // 表单报错 54 | if c.HasError() { 55 | c.Success("manage/new_service") 56 | return 57 | } 58 | 59 | f.Domain = strings.TrimPrefix(f.Domain, "http://") 60 | f.Domain = strings.TrimPrefix(f.Domain, "https://") 61 | f.Domain = strings.TrimRight(f.Domain, "/") 62 | 63 | s := &db.Service{ 64 | Name: f.Name, 65 | Avatar: f.Avatar, 66 | Domain: f.Domain, 67 | Ban: false, 68 | } 69 | 70 | if err := db.CreateService(s); err != nil { 71 | c.RenderWithErr(err.Error(), "manage/new_service", &f) 72 | return 73 | } 74 | c.Flash.Success("添加服务成功") 75 | c.Redirect("/manage/services") 76 | } 77 | 78 | func EditServiceViewHandler(c *context.Context) { 79 | id := c.QueryInt("id") 80 | service, err := db.GetServiceByID(uint(id)) 81 | if err != nil { 82 | if err == db.ErrorServiceNotFound { 83 | c.Flash.Error("服务不存在") 84 | c.Redirect("/manage/services") 85 | return 86 | } 87 | log.Error("Failed to get service: %v", err) 88 | c.Error(errors.New("服务内部错误")) 89 | return 90 | } 91 | 92 | c.Data["Service"] = service 93 | c.Success("manage/edit_service") 94 | } 95 | 96 | func EditServiceActionHandler(c *context.Context, f form.EditService) { 97 | id := c.QueryInt("id") 98 | _, err := db.GetServiceByID(uint(id)) 99 | if err != nil { 100 | if err == db.ErrorServiceNotFound { 101 | c.Flash.Error("服务不存在") 102 | c.Redirect("/manage/services") 103 | return 104 | } 105 | log.Error("Failed to get service: %v", err) 106 | c.Error(errors.New("服务内部错误")) 107 | return 108 | } 109 | 110 | // 表单报错 111 | if c.HasError() { 112 | c.Success("manage/edit_service") 113 | return 114 | } 115 | 116 | f.Domain = strings.TrimPrefix(f.Domain, "http://") 117 | f.Domain = strings.TrimPrefix(f.Domain, "https://") 118 | f.Domain = strings.TrimRight(f.Domain, "/") 119 | 120 | s := &db.Service{ 121 | Model: gorm.Model{ID: uint(id)}, 122 | Name: f.Name, 123 | Avatar: f.Avatar, 124 | Domain: f.Domain, 125 | Ban: false, 126 | } 127 | 128 | if err := db.UpdateService(s); err != nil { 129 | c.RenderWithErr(err.Error(), "manage/new_service", &f) 130 | return 131 | } 132 | c.Flash.Success("修改服务成功") 133 | c.Redirect("/manage/services") 134 | } 135 | 136 | func DeleteServiceViewHandler(c *context.Context) { 137 | id := c.QueryInt("id") 138 | service, err := db.GetServiceByID(uint(id)) 139 | if err != nil { 140 | if err == db.ErrorServiceNotFound { 141 | c.Flash.Error("服务不存在") 142 | c.Redirect("/manage/services") 143 | return 144 | } 145 | log.Error("Failed to get service: %v", err) 146 | c.Error(errors.New("服务内部错误")) 147 | return 148 | } 149 | 150 | c.Data["Service"] = service 151 | c.Success("manage/delete_service") 152 | } 153 | 154 | func DeleteServiceActionHandler(c *context.Context) { 155 | id := c.QueryInt("id") 156 | _, err := db.GetServiceByID(uint(id)) 157 | if err != nil { 158 | if err == db.ErrorServiceNotFound { 159 | c.Flash.Error("服务不存在") 160 | c.Redirect("/manage/services") 161 | return 162 | } 163 | log.Error("Failed to get service: %v", err) 164 | c.Error(errors.New("服务内部错误")) 165 | return 166 | } 167 | 168 | if err := db.DeleteService(uint(id)); err != nil { 169 | c.Flash.Error("删除失败") 170 | } else { 171 | c.Flash.Success("删除服务成功") 172 | } 173 | c.Redirect("/manage/services") 174 | } 175 | -------------------------------------------------------------------------------- /internal/web/manager/setting.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | log "unknwon.dev/clog/v2" 5 | 6 | "github.com/NekoWheel/NekoCAS/internal/context" 7 | "github.com/NekoWheel/NekoCAS/internal/db" 8 | "github.com/NekoWheel/NekoCAS/internal/form" 9 | ) 10 | 11 | func SiteViewHandler(c *context.Context) { 12 | c.Success("manage/site") 13 | } 14 | 15 | func SiteActionHandler(c *context.Context, f form.Site) { 16 | // 表单报错 17 | if c.HasError() { 18 | c.Success("manage/site") 19 | return 20 | } 21 | 22 | if f.OpenRegister { 23 | err := db.SetSetting("open_register", "on") 24 | if err != nil { 25 | log.Error("Failed to set %q to %q", "open_register", "on") 26 | } 27 | } else { 28 | err := db.SetSetting("open_register", "off") 29 | if err != nil { 30 | log.Error("Failed to set %q to %q", "open_register", "off") 31 | } 32 | } 33 | 34 | _ = db.SetSetting("site_logo", f.SiteLogo) 35 | _ = db.SetSetting("mail_whitelist", f.MailWhitelist) 36 | _ = db.SetSetting("privacy", f.Privacy) 37 | 38 | c.Redirect("/manage/site") 39 | } 40 | -------------------------------------------------------------------------------- /internal/web/manager/user.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/NekoWheel/NekoCAS/internal/context" 5 | "github.com/NekoWheel/NekoCAS/internal/db" 6 | ) 7 | 8 | func UsersViewHandler(c *context.Context) { 9 | total := db.CountUsers() 10 | limit := 10 11 | page := c.QueryInt("p") 12 | if page <= 0 { 13 | page = 1 14 | } 15 | 16 | totalPage := int(total/int64(limit)) + 1 17 | if page > totalPage { 18 | page = totalPage 19 | } 20 | 21 | c.Data["From"] = (page-1)*limit + 1 22 | c.Data["To"] = page * limit 23 | 24 | c.Data["NextPage"] = page + 1 25 | c.Data["PreviousPage"] = page - 1 26 | c.Data["LastPage"] = total/int64(limit) + 1 27 | 28 | c.Data["Total"] = total 29 | c.Data["Users"] = db.GetUsers((page-1)*limit, limit) 30 | c.Success("manage/users") 31 | } 32 | -------------------------------------------------------------------------------- /internal/web/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/NekoWheel/NekoCAS/internal/context" 5 | "github.com/NekoWheel/NekoCAS/internal/db" 6 | ) 7 | 8 | // ServicePreCheck 获取 Service 信息中间件 9 | func ServicePreCheck(c *context.Context) { 10 | serviceURL := c.Query("service") 11 | if serviceURL == "" { 12 | c.Service = &db.Service{} 13 | return 14 | } 15 | 16 | service, err := db.ServiceByURL(serviceURL) 17 | if err != nil { 18 | c.Error(err) 19 | return 20 | } 21 | c.ServiceURL = serviceURL 22 | c.Service = service 23 | c.Next() 24 | } 25 | -------------------------------------------------------------------------------- /internal/web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-macaron/binding" 7 | "github.com/go-macaron/cache" 8 | "github.com/go-macaron/csrf" 9 | "github.com/go-macaron/session" 10 | "gopkg.in/macaron.v1" 11 | 12 | "github.com/NekoWheel/NekoCAS/internal/conf" 13 | "github.com/NekoWheel/NekoCAS/internal/context" 14 | "github.com/NekoWheel/NekoCAS/internal/filesystem" 15 | "github.com/NekoWheel/NekoCAS/internal/form" 16 | "github.com/NekoWheel/NekoCAS/internal/spec/v1" 17 | "github.com/NekoWheel/NekoCAS/internal/spec/v2" 18 | "github.com/NekoWheel/NekoCAS/internal/web/account" 19 | "github.com/NekoWheel/NekoCAS/internal/web/manager" 20 | "github.com/NekoWheel/NekoCAS/internal/web/middleware" 21 | "github.com/NekoWheel/NekoCAS/internal/web/template" 22 | "github.com/NekoWheel/NekoCAS/public" 23 | "github.com/NekoWheel/NekoCAS/templates" 24 | ) 25 | 26 | // newMacaron 初始化一个新的 Macaron 实例。 27 | func newMacaron() *macaron.Macaron { 28 | m := macaron.New() 29 | m.Use(macaron.Logger()) 30 | m.Use(macaron.Recovery()) 31 | m.Use(macaron.Statics(macaron.StaticOptions{ 32 | FileSystem: http.FS(public.FS), 33 | }, ".")) 34 | 35 | return m 36 | } 37 | 38 | func Run() { 39 | r := newMacaron() 40 | 41 | var templateFS macaron.TemplateFileSystem 42 | if macaron.Env == macaron.PROD { 43 | templateFS = filesystem.NewFS(templates.FS) 44 | } 45 | 46 | // 登录登出状态 47 | reqSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: true}) 48 | reqSignOut := context.Toggle(&context.ToggleOptions{SignOutRequired: true}) 49 | reqManager := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true}) 50 | 51 | renderOpt := macaron.RenderOptions{ 52 | Directory: "templates", 53 | IndentJSON: macaron.Env != macaron.PROD, 54 | Funcs: template.FuncMap(), 55 | TemplateFileSystem: templateFS, 56 | } 57 | r.Use(macaron.Renderer(renderOpt)) 58 | r.Use(cache.Cacher()) 59 | 60 | bindIgnErr := binding.BindIgnErr 61 | 62 | r.Group("", func() { 63 | // 登录前访问 64 | r.Group("", func() { 65 | r.Combo("/register"). 66 | Get(account.RegisterViewHandler). 67 | Post(bindIgnErr(form.Register{}), account.RegisterActionHandler) 68 | r.Combo("/lost_password").Get(account.LostPasswordHandler).Post(bindIgnErr(form.LostPassword{}), account.LostPasswordActionHandler) 69 | r.Combo("/reset_password").Get(account.ResetPasswordHandler).Post(bindIgnErr(form.ResetPassword{}), account.ResetPasswordActionHandler) 70 | }, reqSignOut) 71 | 72 | // 无论是否已经登录都可以访问 73 | r.Combo("/login", middleware.ServicePreCheck). 74 | Get(account.LoginViewHandler). 75 | Post(bindIgnErr(form.Login{}), account.LoginActionHandler) 76 | r.Any("/activate_code", account.VerifyUserActiveCodeHandler) 77 | r.Get("/privacy", func(c *context.Context) { 78 | c.Success("privacy") 79 | }) 80 | 81 | // 登录后访问 82 | r.Group("", func() { 83 | r.Get("/", account.DashboardViewHandler) 84 | r.Combo("/profile").Get(account.ProfileViewHandler) 85 | r.Combo("/profile/edit").Get(account.ProfileEditViewHandler).Post(bindIgnErr(form.UpdateProfile{}), account.ProfileEditActionHandler) 86 | r.Combo("/logout", middleware.ServicePreCheck).Get(account.LogoutViewHandler).Post(account.LogoutActionHandler) 87 | r.Combo("/activate").Get(account.ActivationViewHandler).Post(account.ActivationActionHandler) 88 | }, reqSignIn) 89 | 90 | // 管理页面 91 | r.Group("/manage", func() { 92 | // 用户 93 | r.Get("/users", manager.UsersViewHandler) 94 | 95 | // 服务 96 | r.Get("/services", manager.ServicesViewHandler) 97 | r.Combo("/services/new").Get(manager.NewServiceViewHandler).Post(bindIgnErr(form.NewService{}), manager.NewServiceActionHandler) 98 | r.Combo("/services/edit").Get(manager.EditServiceViewHandler).Post(bindIgnErr(form.EditService{}), manager.EditServiceActionHandler) 99 | r.Combo("/services/delete").Get(manager.DeleteServiceViewHandler).Post(manager.DeleteServiceActionHandler) 100 | 101 | // 站点设置 102 | r.Combo("/site").Get(manager.SiteViewHandler).Post(bindIgnErr(form.Site{}), manager.SiteActionHandler) 103 | }, reqManager) 104 | 105 | // CAS 协议实现 106 | r.Get("/validate", middleware.ServicePreCheck, v1.ValidateHandler) // v1 107 | r.Get("/serviceValidate", middleware.ServicePreCheck, v2.ValidateHandler) // v2 108 | }, 109 | session.Sessioner(session.Options{ 110 | CookieName: "nekocas", 111 | }), 112 | 113 | csrf.Csrfer(csrf.Options{ 114 | Secret: conf.Site.CSRFKey, 115 | Header: "X-CSRF-Token", 116 | }), 117 | 118 | context.Contexter(), 119 | ) 120 | 121 | r.NotFound(func(c *macaron.Context) { 122 | c.Data["Title"] = "页面不存在" 123 | c.HTML(http.StatusNotFound, "404") 124 | }) 125 | 126 | r.Run(conf.Site.Port) 127 | } 128 | -------------------------------------------------------------------------------- /internal/web/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "html/template" 5 | "time" 6 | ) 7 | 8 | func FuncMap() []template.FuncMap { 9 | return []template.FuncMap{map[string]interface{}{ 10 | "Year": func() int { 11 | return time.Now().Year() 12 | }, 13 | "Safe": func(raw string) template.HTML { 14 | return Safe(raw) 15 | }, 16 | }} 17 | } 18 | 19 | func Safe(raw string) template.HTML { 20 | return template.HTML(raw) 21 | } 22 | -------------------------------------------------------------------------------- /nekocas.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "unknwon.dev/clog/v2" 5 | 6 | "github.com/NekoWheel/NekoCAS/internal/conf" 7 | "github.com/NekoWheel/NekoCAS/internal/db" 8 | "github.com/NekoWheel/NekoCAS/internal/web" 9 | ) 10 | 11 | func main() { 12 | defer log.Stop() 13 | err := log.NewConsole() 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | if err = conf.Load(); err != nil { 19 | log.Fatal("Failed to load config: %v", err) 20 | } 21 | 22 | if err = db.ConnDB(); err != nil { 23 | log.Fatal("Failed to connect to MySQL database: %v", err) 24 | } 25 | 26 | if err = db.ConnRedis(); err != nil { 27 | log.Fatal("Failed to connect to Redis: %v", err) 28 | } 29 | 30 | if conf.Ldap.Enabled { 31 | go db.AutoSyncLdap() 32 | } 33 | 34 | web.Run() 35 | } 36 | -------------------------------------------------------------------------------- /public/fs.go: -------------------------------------------------------------------------------- 1 | package public 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed static 8 | var FS embed.FS 9 | -------------------------------------------------------------------------------- /public/static/NekoWheel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NekoWheel/NekoCAS/b1a91c2971f4475e2f5a204cb02c9a00d36bdb7d/public/static/NekoWheel.png -------------------------------------------------------------------------------- /templates/404.tmpl: -------------------------------------------------------------------------------- 1 | {{template "header" .}} 2 |
3 |一个账号畅享 {{.SiteName}} 所有服务
13 | 14 | {{template "layouts/alert" .}} 15 | 16 |您已安全登出{{ if ne .Service.ID 0 }} {{.Service.Name}}{{end}}。
11 |输入您注册时填写的电子邮箱地址,我们将给您发送一封邮件来帮助您找回密码。
13 | 14 | {{template "layouts/alert" .}} 15 | 16 |51 | | ||||
54 |
55 |
|
104 | ||||
107 | |
51 | | ||||
54 |
55 |
|
104 | ||||
107 | |
服务名 | 40 |白名单域名 | 41 |状态 | 42 |操作 | 43 |
---|---|---|---|
49 |
50 |
52 |
56 |
53 |
55 | {{.Name}}
54 | |
57 | 58 | {{ .Domain }} 59 | | 60 |61 | {{ if .Ban }} 62 | 封禁 63 | {{ else }} 64 | 正常 65 | {{ end }} 66 | | 67 |
68 |
69 |
70 | 编辑
71 |
72 |
73 | 删除
74 |
75 |
76 | |
77 |
昵称 / 电子邮箱 | 25 |激活状态 | 26 |角色 | 27 |操作 | 28 |
---|---|---|---|
34 |
35 |
37 |
42 |
38 |
41 | {{.NickName}}
39 | {{.Email}}
40 | |
43 | 44 | {{ if .IsActive }} 45 | 已激活 46 | {{ else }} 47 | 未激活 48 | {{ end }} 49 | | 50 |51 | {{ if .IsAdmin }} 52 | 管理员 53 | {{ else }} 54 | 用户 55 | {{ end }} 56 | | 57 |
58 |
59 |
60 | 编辑
61 |
62 |
77 |
63 |
67 |
76 |
75 | |
78 |