From 46b7728d24de0ab9df5000641c8e9277ed178ed0 Mon Sep 17 00:00:00 2001 From: Elias Perez Date: Sun, 7 Nov 2021 14:35:35 -0500 Subject: [PATCH] V1.1 (#1) * v1.1 Fixes Entities Why Adds encrypted passwords and basic User and Clients models. These changes prepare the application to allow User and Clients (Apps) registrations. - Renames Models to Entities - Adds Repositories for Clients and Users - Uses UUID for table primary keys - Uses UUID for Client ID - Adds Name and Logo to Clients table - Removes State and Scope from the `/token` request --- Dockerfile | 2 - README.md | 5 ++- db/migrations/1627600350__create_uuid.cr | 9 ++++ db/migrations/1627760477__create_clients.cr | 5 ++- db/migrations/1627760814__create_users.cr | 4 +- docker-compose.yml | 15 +------ migrate.Dockerfile | 7 ---- public/templates/authorize.html | 14 +++---- shard.lock | 2 +- shard.yml | 2 +- spec/authorization_code_spec.cr | 7 +++- spec/helpers/clients_helper.cr | 12 +++--- spec/resource_owner_credentials_spec.cr | 5 ++- spec/session_spec.cr | 5 ++- spec/spec_helper.cr | 15 ++++--- spec/token_spec.cr | 24 ++++++----- src/authority.cr | 4 +- src/config/authly.cr | 15 ++----- src/endpoints/access_token_create_endpoint.cr | 5 ++- src/endpoints/session_create_endpoint.cr | 2 +- src/{models => entities}/client.cr | 12 ++++-- src/entities/user.cr | 41 +++++++++++++++++++ src/models/authorization_code.cr | 27 ------------ src/models/user.cr | 19 --------- src/providers/client_provider.cr | 13 ++++++ src/providers/owner_provider.cr | 13 ++++++ .../client_repo.cr} | 8 ++-- src/repositories/user_repo.cr | 17 ++++++++ src/requests/access_token_create_request.cr | 5 +-- .../authorization_code_show_response.cr | 9 ++-- src/server.cr | 2 + src/services/access_token_service.cr | 4 -- src/services/owner_service.cr | 25 ----------- 33 files changed, 179 insertions(+), 175 deletions(-) create mode 100644 db/migrations/1627600350__create_uuid.cr delete mode 100644 migrate.Dockerfile rename src/{models => entities}/client.cr (55%) create mode 100644 src/entities/user.cr delete mode 100644 src/models/authorization_code.cr delete mode 100644 src/models/user.cr create mode 100644 src/providers/client_provider.cr create mode 100644 src/providers/owner_provider.cr rename src/{services/client_service.cr => repositories/client_repo.cr} (56%) create mode 100644 src/repositories/user_repo.cr delete mode 100644 src/services/owner_service.cr diff --git a/Dockerfile b/Dockerfile index a2e1669..c5a87c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,11 @@ WORKDIR /opt/app COPY . /opt/app RUN shards install RUN crystal build --release --static ./src/server.cr -o ./server -RUN crystal build --release --static ./taskfile.cr -o ./azu CMD ["crystal", "spec"] FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=0 /opt/app/server . -COPY --from=0 /opt/app/azu . COPY --from=0 /opt/app/public ./public CMD ["./server"] \ No newline at end of file diff --git a/README.md b/README.md index f370eeb..1f2c753 100755 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ At this moment Authority issues JWT OAuth 2.0 Access Tokens as default. Grant Types - [x] Authorization code grant +- [x] Client credentials grant - [x] Implicit grant - [x] Resource owner credentials grant -- [x] Client credentials grant - [x] Refresh token grant - [x] OpenID Connect - [x] PKCE +- [ ] Device Code grant - [ ] Token Introspection - [ ] Token Revocation @@ -105,7 +106,7 @@ docker-compose up server ## Contributing -1. Fork it (https://github.com/azutoolkit/authority/fork) +1. Fork it () 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) diff --git a/db/migrations/1627600350__create_uuid.cr b/db/migrations/1627600350__create_uuid.cr new file mode 100644 index 0000000..728cbdc --- /dev/null +++ b/db/migrations/1627600350__create_uuid.cr @@ -0,0 +1,9 @@ +class CreateUUID + include Clear::Migration + + def change(direction) + direction.up do + execute %(CREATE EXTENSION IF NOT EXISTS "uuid-ossp";) + end + end +end diff --git a/db/migrations/1627760477__create_clients.cr b/db/migrations/1627760477__create_clients.cr index 4da73a5..308a173 100644 --- a/db/migrations/1627760477__create_clients.cr +++ b/db/migrations/1627760477__create_clients.cr @@ -3,10 +3,11 @@ class CreateClients def change(direction) direction.up do - create_table :clients do |t| - t.column :client_id, "uuid", null: false, index: true, unique: true + create_table :clients, id: :uuid do |t| + t.column :client_id, "uuid", index: true, unique: true, default: "uuid_generate_v4()" t.column :name, "varchar(120)", null: false, index: true, unique: true t.column :description, "varchar(2000)" + t.column :logo, "varchar(120)", null: false t.column :client_secret, "varchar(80)", null: false t.column :redirect_uri, "varchar(2000)", null: false t.column :scopes, "varchar(4000)", null: false diff --git a/db/migrations/1627760814__create_users.cr b/db/migrations/1627760814__create_users.cr index 587194d..e85d816 100644 --- a/db/migrations/1627760814__create_users.cr +++ b/db/migrations/1627760814__create_users.cr @@ -3,9 +3,9 @@ class CreateUser def change(direction) direction.up do - create_table :users do |t| + create_table :users, id: :uuid do |t| t.column :username, "varchar(80)", null: false, index: true, unique: true - t.column :password, "varchar(80)", null: false + t.column :encrypted_password, "varchar(80)", null: false t.column :first_name, "varchar(80)", null: false t.column :last_name, "varchar(80)", null: false t.column :email, "varchar(80)", null: false diff --git a/docker-compose.yml b/docker-compose.yml index 1fd757f..3b11abd 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,19 +10,6 @@ services: ports: - 5432:5432 - migrator: - build: - context: . - dockerfile: migrate.Dockerfile - container_name: migrator - working_dir: /root/ - env_file: - - local.env - ports: - - "4000:4000" - depends_on: - - db - server: build: context: . @@ -35,4 +22,4 @@ services: ports: - "4000:4000" depends_on: - - migrator + - db diff --git a/migrate.Dockerfile b/migrate.Dockerfile deleted file mode 100644 index 4a15469..0000000 --- a/migrate.Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ - -FROM crystallang/crystal:latest-alpine -WORKDIR /opt/app -COPY . /opt/app -RUN shards install -RUN crystal build --release --static ./taskfile.cr -o ./azu -CMD ["./azu", "db", "migrate"] \ No newline at end of file diff --git a/public/templates/authorize.html b/public/templates/authorize.html index 0cc948c..00d4536 100644 --- a/public/templates/authorize.html +++ b/public/templates/authorize.html @@ -33,8 +33,6 @@ } } - - @@ -50,19 +48,19 @@
-
-

Authorize {{client.name}}

-
+
{{client.name}}

{{client.description}}

diff --git a/shard.lock b/shard.lock index aaf9eb7..9dd059d 100755 --- a/shard.lock +++ b/shard.lock @@ -7,7 +7,7 @@ shards: authly: git: https://github.com/azutoolkit/authly.git - version: 0.3+git.commit.732bf4dd11e4d0f1d4f0d17365f89042d04773d3 + version: 1.1.1 azu: git: https://github.com/azutoolkit/azu.git diff --git a/shard.yml b/shard.yml index bdf49ee..ddc592a 100755 --- a/shard.yml +++ b/shard.yml @@ -20,7 +20,7 @@ dependencies: branch: master authly: github: azutoolkit/authly - version: 1.0 + version: 1.1.1 development_dependencies: faker: diff --git a/spec/authorization_code_spec.cr b/spec/authorization_code_spec.cr index 6bd6474..b370a98 100755 --- a/spec/authorization_code_spec.cr +++ b/spec/authorization_code_spec.cr @@ -3,10 +3,13 @@ require "./spec_helper" describe Authority do describe "Authorization Code Flow" do it "gets access token" do - user = create_owner + password = Faker::Internet.password + user = create_owner(password: password) state = Random::Secure.hex auth_url = OAUTH_CLIENT.get_authorize_uri(scope: "read", state: state) - code, expected_state = AuthorizationCodeFlux.flow(auth_url, user.username, user.password) + + code, expected_state = AuthorizationCodeFlux.flow( + auth_url, user.username, password) token = OAUTH_CLIENT.get_access_token_using_authorization_code(code) diff --git a/spec/helpers/clients_helper.cr b/spec/helpers/clients_helper.cr index 1e4e1b6..5e176d8 100644 --- a/spec/helpers/clients_helper.cr +++ b/spec/helpers/clients_helper.cr @@ -1,11 +1,11 @@ def create_client(client_id, client_secret, redirect_uri) - client = Authority::Client.new({ + Authority::Client.new({ client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri, - grant_types: "cleint_credentials", - scope: "read", - }) - - client.save! + name: Faker::Company.name, + description: Faker::Lorem.paragraph(2), + logo: Faker::Company.logo, + scopes: "read", + }).save! end diff --git a/spec/resource_owner_credentials_spec.cr b/spec/resource_owner_credentials_spec.cr index e59653c..ba8d109 100644 --- a/spec/resource_owner_credentials_spec.cr +++ b/spec/resource_owner_credentials_spec.cr @@ -3,10 +3,11 @@ require "./spec_helper" describe Authority do describe "Password Flow" do it "gets access token" do - user = create_owner + password = Faker::Internet.password + user = create_owner(password: password) token = OAUTH_CLIENT.get_access_token_using_resource_owner_credentials( - username: user.username, password: user.password, scope: "read" + username: user.username, password: password, scope: "read" ) token.should be_a OAuth2::AccessToken::Bearer diff --git a/spec/session_spec.cr b/spec/session_spec.cr index 4d944cd..d2c97ae 100644 --- a/spec/session_spec.cr +++ b/spec/session_spec.cr @@ -15,9 +15,10 @@ describe Authority do describe "Create" do it "create a new session" do + password = Faker::Internet.password session_flux = SessionFlux.new - user = create_owner - result = session_flux.create user.username, user.password + user = create_owner(password: password) + result = session_flux.create user.username, password result.should be_a URI::Params end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 31f0968..08e01a9 100755 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -8,21 +8,24 @@ require "./helpers/**" require "./flows/**" require "../src/authority" -CLIENT_ID = Faker::Internet.user_name +CLIENT_ID = UUID.random.to_s CLIENT_SECRET = Faker::Internet.password(32, 32) REDIRECT_URI = "http://www.example.com/callback" OAUTH_CLIENT = OAuth2::Client.new( - "localhost", CLIENT_ID, CLIENT_SECRET, port: 4000, scheme: "http", - redirect_uri: REDIRECT_URI, authorize_uri: "/authorize", token_uri: "/token") + "localhost", + CLIENT_ID, + CLIENT_SECRET, + port: 4000, + scheme: "http", + redirect_uri: REDIRECT_URI, + authorize_uri: "/authorize", + token_uri: "/token") -Clear::SQL.truncate("authorization_codes", cascade: true) Clear::SQL.truncate("users", cascade: true) Clear::SQL.truncate("clients", cascade: true) -puts "Creating Clien: #{CLIENT_ID} #{CLIENT_SECRET}" create_client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI) Spec.before_each do - Clear::SQL.truncate("authorization_codes", cascade: true) Clear::SQL.truncate("users", cascade: true) end diff --git a/spec/token_spec.cr b/spec/token_spec.cr index 6c10c31..8017b13 100644 --- a/spec/token_spec.cr +++ b/spec/token_spec.cr @@ -1,18 +1,20 @@ require "./spec_helper" describe "TokenSpec" do + password = Faker::Internet.password + describe "OpenID" do it "returns id_token" do - user = create_owner + user = create_owner(password: password) + scope = "openid read" - code, code_verifier, expected_state = prepare_code_challenge_url(user.username, user.password, "S256", scope) + code, code_verifier, expected_state = prepare_code_challenge_url( + user.username, password, "S256", scope) response = create_token_request(code, code_verifier, scope) token = OAuth2::AccessToken::Bearer.from_json(response.body) - p response.body + id_token = token.extra.not_nil!["id_token"] - p Authly.config.secret_key - p id_token id_token.should_not be_nil end end @@ -28,8 +30,9 @@ describe "TokenSpec" do describe "Create Token" do describe "Method S256" do it "creates access token" do - user = create_owner - code, code_verifier, expected_state = prepare_code_challenge_url(user.username, user.password, "S256") + user = create_owner(password: password) + code, code_verifier, expected_state = prepare_code_challenge_url( + user.username, password, "S256") response = create_token_request(code, code_verifier) token = OAuth2::AccessToken::Bearer.from_json(response.body) @@ -41,14 +44,13 @@ describe "TokenSpec" do describe "Method PLAIN" do it "creates access token" do - user = create_owner - code, code_verifier, expected_state = prepare_code_challenge_url(user.username, user.password, "plain") + user = create_owner(password: password) + code, code_verifier, expected_state = prepare_code_challenge_url( + user.username, password, "plain") response = create_token_request(code, code_verifier) token = OAuth2::AccessToken::Bearer.from_json(response.body) - p token - response.status_message.should eq "OK" token.should be_a OAuth2::AccessToken::Bearer end diff --git a/src/authority.cr b/src/authority.cr index 5bc4e8e..efbed84 100755 --- a/src/authority.cr +++ b/src/authority.cr @@ -22,7 +22,9 @@ end require "./config/**" require "./services/**" require "./requests/**" +require "./providers/**" require "./responses/**" -require "./models/**" +require "./entities/**" +require "./repositories/**" require "./endpoints/**" require "./config/**" diff --git a/src/config/authly.cr b/src/config/authly.cr index 204db45..4421daa 100644 --- a/src/config/authly.cr +++ b/src/config/authly.cr @@ -1,18 +1,9 @@ # Configure Authly.configure do |c| - # Secret Key for JWT Tokens c.secret_key = "ExampleSecretKey" - - # Refresh Token Time To Live c.refresh_ttl = 1.hour - - # Authorization Code Time To Live - c.code_ttl = 1.hour - - # Access Token Time To Live + c.code_ttl = 3.minutes c.access_ttl = 1.hour - - # Using your own classes - c.owners = Authority::OwnerService.new - c.clients = Authority::ClientService.new + c.owners = Authority::OwnerProvider.new + c.clients = Authority::ClientProvider.new end diff --git a/src/endpoints/access_token_create_endpoint.cr b/src/endpoints/access_token_create_endpoint.cr index 506e342..7f6cfa6 100644 --- a/src/endpoints/access_token_create_endpoint.cr +++ b/src/endpoints/access_token_create_endpoint.cr @@ -9,10 +9,13 @@ module Authority post "/token" def call : AccessTokenCreateResponse - access_token = AccessTokenService.access_token *credentials, access_token_create_request AccessTokenCreateResponse.new access_token end + private def access_token : Authly::AccessToken + AccessTokenService.access_token *credentials, access_token_create_request + end + private def credentials value = header[AUTH] client_id, client_secret = Base64.decode_string(value[BASIC.size + 1..-1]).split(":") diff --git a/src/endpoints/session_create_endpoint.cr b/src/endpoints/session_create_endpoint.cr index 330a411..9873758 100644 --- a/src/endpoints/session_create_endpoint.cr +++ b/src/endpoints/session_create_endpoint.cr @@ -40,7 +40,7 @@ module Authority end private def authorized? - OwnerService.new.authorized?( + Authly.owners.authorized?( session_create_request.username, session_create_request.password ) diff --git a/src/models/client.cr b/src/entities/client.cr similarity index 55% rename from src/models/client.cr rename to src/entities/client.cr index 728af90..a78396f 100644 --- a/src/models/client.cr +++ b/src/entities/client.cr @@ -5,12 +5,16 @@ module Authority self.table = "clients" - primary_key - column client_id : String + primary_key :id, type: :uuid + + column name : String + column client_id : UUID column client_secret : String column redirect_uri : String - column grant_types : String - column scope : String + column description : String + column name : String + column logo : String + column scopes : String timestamps end diff --git a/src/entities/user.cr b/src/entities/user.cr new file mode 100644 index 0000000..c86420b --- /dev/null +++ b/src/entities/user.cr @@ -0,0 +1,41 @@ +# Model Docs - https://clear.gitbook.io/project/model/column-types +module Authority + class User + include Clear::Model + + self.table = "users" + + primary_key :id, type: :uuid + column username : String + column email : String + column first_name : String + column last_name : String + column email_verified : Bool = false + column scope : String + column encrypted_password : Crypto::Bcrypt::Password + timestamps + + def password=(plain_text : String) + self.encrypted_password = Crypto::Bcrypt::Password.create(plain_text) + end + + def verify?(password : String) + self.encrypted_password.verify(password) + end + + def id_token + { + "user_id" => id.to_s, + "first_name" => first_name, + "last_name" => last_name, + "email" => email, + "scope" => scope, + "email_verified" => email_verified.to_s, + "created_at" => created_at.to_s, + "updated_at" => updated_at.to_s, + "iat" => Time.utc.to_unix, + "exp" => 1.hour.from_now.to_unix, + } + end + end +end diff --git a/src/models/authorization_code.cr b/src/models/authorization_code.cr deleted file mode 100644 index 2c7dc70..0000000 --- a/src/models/authorization_code.cr +++ /dev/null @@ -1,27 +0,0 @@ -# Model Docs - https://clear.gitbook.io/project/model/column-types -module Authority - class AuthorizationCode - include Clear::Model - - self.table = "authorization_codes" - - primary_key - - column authorization_code : String - column client_id : String - column user_id : String - column redirect_uri : String - column expires : Time - column scope : String - column id_token : String - column code_challenge : String?, presence: false - column code_challenge_method : String?, presence: false - - column created_at : Time, presence: false - column updated_at : Time, presence: false - - def expired? - Time.utc > expires - end - end -end diff --git a/src/models/user.cr b/src/models/user.cr deleted file mode 100644 index 60b0650..0000000 --- a/src/models/user.cr +++ /dev/null @@ -1,19 +0,0 @@ -# Model Docs - https://clear.gitbook.io/project/model/column-types -module Authority - class User - include Clear::Model - - self.table = "users" - - primary_key - column username : String - column password : String - column first_name : String - column last_name : String - column email : String - column email_verified : Bool - column scope : String - - timestamps - end -end diff --git a/src/providers/client_provider.cr b/src/providers/client_provider.cr new file mode 100644 index 0000000..72b61c2 --- /dev/null +++ b/src/providers/client_provider.cr @@ -0,0 +1,13 @@ +module Authority + class ClientProvider + include Authly::AuthorizableClient + + def valid_redirect?(client_id : String, redirect_uri : String) : Bool + ClientRepo.valid_redirect?(client_id, redirect_uri) + end + + def authorized?(client_id : String, client_secret : String) : Bool + ClientRepo.authorized?(client_id, client_secret) + end + end +end diff --git a/src/providers/owner_provider.cr b/src/providers/owner_provider.cr new file mode 100644 index 0000000..58803ca --- /dev/null +++ b/src/providers/owner_provider.cr @@ -0,0 +1,13 @@ +module Authority + class OwnerProvider + include Authly::AuthorizableOwner + + def authorized?(username : String, password : String) : Bool + UserRepo.authenticate? username, password + end + + def id_token(user_id : String) : Hash(String, Int64 | String) + UserRepo.id_token user_id + end + end +end diff --git a/src/services/client_service.cr b/src/repositories/client_repo.cr similarity index 56% rename from src/services/client_service.cr rename to src/repositories/client_repo.cr index 17e0151..e720991 100644 --- a/src/services/client_service.cr +++ b/src/repositories/client_repo.cr @@ -1,15 +1,13 @@ module Authority - class ClientService - include Authly::AuthorizableClient - - def valid_redirect?(client_id : String, redirect_uri : String) : Bool + module ClientRepo + def self.valid_redirect?(client_id : String, redirect_uri : String) : Bool Client.query.find!({client_id: client_id, redirect_uri: redirect_uri}) true rescue e false end - def authorized?(client_id : String, client_secret : String) : Bool + def self.authorized?(client_id : String, client_secret : String) : Bool Client.query.find!({client_id: client_id, client_secret: client_secret}) true rescue e diff --git a/src/repositories/user_repo.cr b/src/repositories/user_repo.cr new file mode 100644 index 0000000..387927c --- /dev/null +++ b/src/repositories/user_repo.cr @@ -0,0 +1,17 @@ +module Authority + module UserRepo + def self.authenticate?(username : String, password : String) + find!(username).try &.verify?(password) + rescue e + false + end + + def self.id_token(user_id : String) + find!(user_id).try &.id_token + end + + private def self.find!(username : String) + User.query.find!({username: username}) + end + end +end diff --git a/src/requests/access_token_create_request.cr b/src/requests/access_token_create_request.cr index 9a202d0..b7fc49f 100644 --- a/src/requests/access_token_create_request.cr +++ b/src/requests/access_token_create_request.cr @@ -4,17 +4,14 @@ module Authority include Request getter grant_type : String - getter redirect_uri : String = "" getter code : String = "" - getter scope : String - getter state : String = "" + getter redirect_uri : String = "" getter username : String = "" getter password : String = "" getter refresh_token : String = "" getter code_verifier : String = "" validate grant_type, message: "Param grant_type must be present.", presence: true - validate scope, message: "Param scope must be present.", presence: false validate username, presence: false validate password, presence: false diff --git a/src/responses/authorization_code_show_response.cr b/src/responses/authorization_code_show_response.cr index 5ccb3e0..3de176c 100644 --- a/src/responses/authorization_code_show_response.cr +++ b/src/responses/authorization_code_show_response.cr @@ -23,11 +23,12 @@ module Authority code_challenge_method: authorize_show_request.code_challenge_method, response_type: authorize_show_request.response_type, client: { - client_id: client.client_id, + client_id: client.client_id.to_s, redirect_uri: client.redirect_uri, - name: "Acme App", - description: "This example is a quick exercise to illustrate how the bottom navbar works.", - scopes: client.scope, + name: client.name, + logo: client.logo, + description: client.description, + scopes: client.scopes, }, } end diff --git a/src/server.cr b/src/server.cr index a40f797..b054cbd 100644 --- a/src/server.cr +++ b/src/server.cr @@ -1,4 +1,6 @@ require "./authority" +require "../db/migrations/**" +Clear::Migration::Manager.instance.apply_all # Start your server # Add Handlers to your App Server diff --git a/src/services/access_token_service.cr b/src/services/access_token_service.cr index 4e3bbc3..35328b9 100644 --- a/src/services/access_token_service.cr +++ b/src/services/access_token_service.cr @@ -1,7 +1,5 @@ module Authority class AccessTokenService - getter code_verifier : String = "" - def self.access_token(client_id, client_secret, access_token_req) new(client_id, client_secret, access_token_req).access_token end @@ -22,8 +20,6 @@ module Authority password: @access_token_req.password, redirect_uri: @access_token_req.redirect_uri, code: @access_token_req.code, - state: @access_token_req.state, - scope: @access_token_req.scope, verifier: @access_token_req.code_verifier, refresh_token: @access_token_req.refresh_token, ) diff --git a/src/services/owner_service.cr b/src/services/owner_service.cr deleted file mode 100644 index 58547cb..0000000 --- a/src/services/owner_service.cr +++ /dev/null @@ -1,25 +0,0 @@ -module Authority - class OwnerService - include Authly::AuthorizableOwner - - def authorized?(username : String, password : String) : Bool - User.query.find!({username: username, password: password}) - true - rescue e - false - end - - def id_token(user_id : String) : Hash(String, String) - owner = User.query.find!({username: user_id}) - - { - "user_id" => owner.id.to_s, - "first_name" => owner.first_name, - "last_name" => owner.last_name, - "email" => owner.email, - "created_at" => owner.created_at.to_s, - "updated_at" => owner.updated_at.to_s, - } - end - end -end