---
# System prepended metadata

title: Ruby on Rails 2023/11/27  - 會員系統
tags: [Ruby]

---

Ruby on Rails 2023/11/27  - 會員系統
===

今日重點：
- User 
  - sign up
    - password hashed
    - salt
  - sign in
    - session / cookie
  - current_user
  - user_signed_in?


# 註冊

### 建立model

```ternimal
rails g model User email password
```
- migration
```ruby
# 20231127030550_create_users
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email
      t.string :password

      t.timestamps
    end
    add_index :users, :email, unique: true
      #增加這行
  end
end
```
   
執行`rails db-migrate`

### model驗證
`validates :email, uniqueness:true`

- 任何加在model的驗證都不是對資料庫，而是對model
- 在migration內做驗證，可以有兩層鎖，model驗證&database驗證
- add_index 加索引，載入速度會變慢，也會建索引，unique:true


- model 寫入驗證
```ruby
class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  validates :password, presence: true, length: {minimum:3, maximum:20}
end
```

### routes
有些是有id 有些是沒id
哪些時候需要**沒有**id? 購物車,會員系統

### resource or resources?
如果你知道沒有id，resource 就用單數寫法
如果是購物車和user就不需要id()
單數就沒有show

```ruby!
#routes.rb
  resource :users, except: [:destroy]
```

### 到navbra新增註冊連結
```ruby
<%= link_to  "註冊", new_users_path %>
```

### 利用終端機加controller
```
rails g controller Users
```
- 因為resources是複數，所以此處的Users也是

### Create

- users.controller
```
  def new
    @user = User.new
  end
```

- new.html.erb
- 增加表單小幫手

```ruby!
#new.html.erb
<h1 class="text-2xl">註冊帳號</h1>

<%= form_with model: @user, data: { turbo: false } do |form| %>

  <div>
    <%= form.label :email %>
    <%= form.email_field :email, class: 'input-field' %>
  </div>

  <div>
    <%= form.label :password %>
    <%= form.password_field :password, class: 'input-field' %>
  </div>

  <div>
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation, class: 'input-field' %>
  </div>

  <div>
    <%= form.submit class: 'ink-btn' %>
  </div>
<% end %>
```
css

```
.input-field {
  @apply border-2 border-black
}
```

### model增加validates-confirmation

到model
```ruby
#users.rb
  validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true
```
- 此時Rails會幫忙增加一個虛擬的表單

new.html.erb加`:password_confirmation`
```
#new.html.erb
<div>
    <%= f.label :password_confirmation%>
    <%= f.password_field :password_confirmation, class: 'input-field' %>
</div>
```

users_controller做一個user_params

```ruby!
# users_controller
  def user_params
    params.require(:user)
          .permit(:email, :password, :password_confirmation)
  end
```

- 因為有寫confirmation，所以會做password_confirmation一個虛擬欄位

把create寫完
```ruby!
  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to root_path, notice: '註冊成功'
    else
      render :new
    end
  end
```

進去`rails c` 試試看以下指令
```
irb(main):002> Digest::MD5.hexdigest('123')
=> "202cb962ac59075b964b07152d234b70"
```
### 加密（其實是雜湊）


- 設定加密（其實是雜湊），為了不想讓人看到密碼長怎麼，不管是系統管理員or駭客
- 在存擋前面，本身這個物件的password，利用Digest::SHA.hexdigest把密碼變成奇怪的樣子，加密的東西放model
- 想要在密碼存入前就改變，不要將原始密碼存入資料庫，就可以用before_save
```ruby!
#users.rb
  before_save :encrypt_password
  #after_save :send_text

  private


  def encrypt_password
    self.password = Digest::SHA1.hexdigest(self.password)
  end
```

- Salting
在寫進去任意固定位置插入特定的字串，可以想像成灑鹽巴，可以延長被猜出來的機會
在user.rb，`encrypt_password`方法內寫入`self.password = Digest::SHA1.hexdigest(self.password)`

```ruby
#users.rb
  def encrypt_password
    salted_password = hashed_password = "*xx#{self.paddword}yy-"
    self.password = Digest::SHA1.hexdigest(self.password)
  end
```
- 內部專案使用salting沒關係，但是開源專案會有問題
- 其實不能用before_save，因為create,update都會有，所以改用before_create
- 使用SHA256，而非SHA1

```ruby!
#users.rb
class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true

  before_create :encrypt_password
  # before_save :encrypt_password
  #after_save :send_text

  private

  #設定加密，為了不想讓人看到密碼長怎麼，不管是系統管理員or駭客
  #在存擋前面，本身這個物件的password，利用Digest::SHA.hexdigest把密碼變成奇怪的樣子
  def encrypt_password
    salted_password = hashed_password = "*xx#{self.paddword}yy-"
    self.password = Digest::SHA256.hexdigest(salted_password)
  end
end
```

### 二階段驗證
- 除了密碼之後，還會有簡訊or其他軟體驗證

## 登入
- session
    - 登入之後發號碼牌給使用者
    - `resource :sessions, only: [:create, :destroy]`
    - 不需要ID，所以單數就好，需要某些動詞即可，所以使用only
    - 應該把大部分東西當資源來看，寫法和CRUD一樣
    - 使用Create
- cookie


```ruby
routes.rb
  get '/users/sign_in', to: 'users#sign_in'
```
- 要去哪裡其實都可以，不是強制，只是有關使用者體驗
- 除了原本的路徑以為，Rails可以幫你擴充其他路徑

### routes擴充
```ruby!
  resources :products do
    member do
      get :mm
      get :download
    end
    #寫成member帶有id
    #會出現/products/:id/mm
    #需要ID寫在member裏面
    
    collection do
      get :cc
      get :special_sell
      #會出現/products/mm
      #不需要id可以寫在collection
    end
  end
```

### 幫原本的user擴充路徑
根據上述改寫
```ruby
  resource :users, except: [:destroy] do
    collection do
      get :sign_in #登入表單
    end
  end
```
![截圖 2023-11-27 下午2.10.16](https://hackmd.io/_uploads/ByL172bHp.png)

### 加上登入的連結
```ruby!
<%= link_to  "登入帳號", sign_in_users_path	 %>
```


### form_for vs form_tag
- form_for(@product)
    - 能做到類似form_with的效果，一定要model
    - 能夠猜是新的還是舊的，新的幫你造、舊的幫你更新
- form_tag(url: 'bmi_calc')
    - 不需要model

- form_with(model: @a)
    - Rails 5以上才使用
    - 有model是form_for，沒有model是form_tag
    - 能夠整合以上兩種
    - @a這個字，和方法定義的有關，ex: @a = User.new
    - form_with() -> /users/new，因為nil所以默默轉成form_tag

### 製作from表單
```ruby!
# sign_in.html.erb
<h1 class="text-2xl">登入帳號</h1>

<%= form_with model: @user, data: { turbo: false } do |form| %>

  <div>
    <%= form.label :email %>
    <%= form.email_field :email, class: 'input-field' %>
  </div>

  <div>
    <%= form.label :password %>
    <%= form.password_field :password, class: 'input-field' %>
  </div>

  <div>
    <%= form.submit '登入', class: 'ink-btn' %>
  </div>
<% end %>
```

改寫scope，就不用生一個東西出來
```ruby!
# sign_in.html.erb

<h1 class="text-2xl">登入帳號</h1>

<%= form_with (scope: 'user', data: { turbo: false }) do |form| %>
# 這裡改寫
  <div>
    <%= form.label :email %>
    <%= form.email_field :email, class: 'input-field' %>
  </div>

  <div>
    <%= form.label :password %>
    <%= form.password_field :password, class: 'input-field' %>
  </div>

  <div>
    <%= form.submit '登入', class: 'ink-btn' %>
  </div>
<% end %>
```
[form_with](https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with)

- 但我們是想要把這個表單送出到session的create，所以自己寫路徑

### 新增session的routes
```ruby!
#routes.rb
  resource :sessions, only: [:create, :destroy]
```

### 改寫form，加上
```ruby!
<%= form_with(url: users_sessions_path,
              scope: 'user',
              data: {turbo: false}) do |form| %>
```

- 原本就是POST所以不用加


到sessions.controller寫
```ruby!
#sessions.controller
    user = User.login(param[:user])

    if (user)

    else
      redirec_to sign_in_users_path, alert: '登入失敗'
    end
```

### Fat Model, Thin Controller
把邏輯往model塞，Controller精簡，越簡單越好，
因為model是最容易被重複使用，view也能重複使用，Controller很少重複使用

### 實體方法
```ruby!
class Cat
end

kitty = Cat.new

def kitty.eat
    puts kitty.eat
    puts "hi"
end
```
- 單體方法singleton method可以定義實體方法&類別方法
- eat是個實體方法（instance method），作用在`kitty`上
- `Cat.hello`，是類別方法（class method），作用在類別身上的方法
- 在Class內如果要定義類別方法，前面要加self

```ruby!
class Cat
    def slef.hello
    end
end
```

### 把方法放進去mode
```ruby!
#user.rb
  def self.login(user_params)
    email = user_params[:email]
    password = user_params[:password]
    salted_password = Digest::SHA256.hexdigest(hashed_password = "*xx#{password}yy-")

    find_by(email: email, password: salted_password)
  end
```

可以改寫成
```ruby!
  def self.login(user_params)
    email = user_params[:email]
    password = Digest::SHA256.hexdigest("*xx#{user_params[:password]}yy-")

    find_by(email: email, password: password)
  end
```

改寫變數
```ruby
  def self.login(data)
    email = data[:email]
    password = Digest::SHA256.hexdigest("*xx#{data[:password]}yy-")

    find_by(email: email, password: password)
  end
```

key和value一樣可以改寫
```ruby=
find_by(email: , password: )
```

### 登入成功 ＆ 失敗
```ruby=
sessions_controller.rb

    if (user)
      redirect_to sign_in_users_path, notice: '登入成功'
    else
      redirect_to sign_in_users_path, alert: '登入失敗'
    end
```

### 發號碼牌
- 和flash很像，可以給session[:key]
```ruby=
flash[:notice] = '1111'
session[:__user_ticket__] = ???
```
- 可以給個不會重複的東西
```ruby=
    if (user)
      #發號碼牌
      session[:__user_ticket__] = user.id
      redirect_to sign_in_users_path, notice: '登入成功'
    else
      redirect_to sign_in_users_path, alert: '登入失敗'
    end
```

### session & cookie ? 
- cookie在server(客人那)，ex:客人帶facebook專屬cookie出去
- session在broswer（店家這）
- 發號碼牌時後台要有一樣的ticket（就像是領飲料的流程）
- session和cookie要對得起來，你有cookie，我有session，我們對在一起才能登入
- 清cookie等於把網站登出，cookie會定期清，cookie過期就對不起來
- session reset的話，cookie號碼牌也撕掉，要重登
- 一般瀏覽器沒事不會把你踢出去
- 忘記裝置 = 清掉session
- 每一個request都會有cookie

### 要拿以下這一大串東西
![截圖 2023-11-27 下午4.24.16](https://hackmd.io/_uploads/rkuBfRZST.png)

### 渲染頁面，讓使用者知道登入了
```ruby
user-id: <%= session[:__users_ticket__]%>
```

### 製作小幫手
- 因為上面那段太長了，使用/helper來寫
- helper是製作controller時長出來的
- 所有的小幫手可以拿，沒有限制在哪一個頁面都可以，rails啟動時都會長出來


### helper
```
#Helper
module SessionsHelper
  def current_user
    session[:__user_ticket__]
  end
end
```
- 載入順序，越下面排序越前面
- 有id了用find找user

用id找使用者
```
#UsersHelper
module UsersHelper
  def current_user
    User.find_by(id: session[:__user_ticket__])
  end
end
```

layout改顯示email
#/layouts/application.html.erb
```ruby
user-id: <%= current_user.email %>
```

- 選擇自己相信的單位
- 如果改`current_user.password`，就會出現加密後的亂碼
- ?，是否，回傳為boolean
- ruby搜尋empty? -> 是不是空的

### 判斷使用者是否登入
寫在helper
```ruby
#usershelper
  def user_signed_in?
    current_user.present?
  end
```
### navbar改寫

```ruby
      <% if user_signed_in? %>
        <li><%= current_user.email %></li>
        <li><%= link_to '登出', '#' %></li>
      <% else %>
        <li><%= link_to '註冊', new_users_path %></li>
        <li><%= link_to '登入', sign_in_users_path %></li>
      <% end %>
```

- 無痕模式為什麼壞掉？因為沒有判斷有沒有登入，把layout的`user-id: <%= current_user.email %>`拿掉

### 會員套件 bundle add devise
$ bundle add devise
$ rails g devise:install
$ rails g devise User
$ rails db:migrate
請參考：[devise](https://github.com/heartcombo/devise)

## 登出

### 增加登出連結
```ruby!
#navar.html.erb
<li><%= link_to '登出', sessions_path, data: {turbo_method:'delete'} %></li>
```

- <a>一般超連結只有get屬性
- 為什麼會有這個效果？
    Rails會去攔截頁面上的超連結，如果點擊發現有對到他的設定，例如像method，JavaScript就能幫助你做想做的事
    在其他框架沒有這個效果
- fetch...then
    
### 在controller增加destory方法？
#sessions_controller.rb
```ruby!
  def destroy
    session[@__user_ticket__] == nil
    redirect_to root_path, notice: '已登出'
  end
```
- 會遇到：device登出，購物車做在session裡，東西也會跟著清掉
    解決辦法：購物車不要放session裡
    參考：[rails session](https://www.writesoftwarewell.com/rails-sessions/)
    
- 如果沒有就拿123(邏輯短路造成的回串值)
```
h[:name] || 123
-> 123

h.fetch(:name, 123)
-> false
```
- fetch是看key有沒有存在

#### falsy
- ruby falsy -> nill & false（只有兩種）
- [js falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) -> null, undefined, false, NaN, 0, -0, 0n, "", document.all

#### dig
[api](https://apidock.com/ruby/Hash/dig)
```ruby
irb(main):001> h = {foo: {bar: {baz: 2}}}
=> {:foo=>{:bar=>{:baz=>2}}}
irb(main):002> h.dig(:foo)
=> {:bar=>{:baz=>2}}
```
    
### 應該使用delete方法
```ruby
# sessions_controller.rb
  def destroy
    session.delete(:__user_ticket__)
    redirect_to root_path, notice: '已登出'
  end
```